From c6ed26b064e5825a90d149bcf8226f1e4b2dba36 Mon Sep 17 00:00:00 2001 From: Stuart Spradling Date: Wed, 25 Mar 2020 10:02:36 -0500 Subject: [PATCH 1/9] merged sp/issue-102 and added a producer to the webview --- README.md | 11 +- images/webproducer-sendmessage.PNG | Bin 0 -> 45052 bytes images/webproducer.PNG | Bin 0 -> 42201 bytes kafka-webview-ui/pom.xml | 7 + kafka-webview-ui/src/main/frontend/js/app.js | 14 + .../src/main/frontend/package-lock.json | 537 ---------------- .../ui/configuration/DataLoaderConfig.java | 35 +- .../ui/configuration/PluginConfig.java | 12 + .../webview/ui/controller/BaseController.java | 7 + .../ui/controller/api/ApiController.java | 83 +-- .../api/requests/SendMessageRequest.java | 60 ++ .../MessageFormatController.java | 273 +------- .../forms/MessageFormatForm.java | 123 +--- .../PartitionStrategyController.java | 138 +++++ .../forms/PartitioningStrategyForm.java | 34 + .../producer/ProducerConfigController.java | 257 ++++++++ .../producer/forms/ProducerForm.java | 155 +++++ .../view/ViewConfigController.java | 4 +- .../producer/ProducerController.java | 149 +++++ .../controller/EntityUsageManager.java | 153 +++++ .../UploadableJarControllerHelper.java | 416 +++++++++++++ .../manager/controller/UploadableJarForm.java | 140 +++++ .../kafka/producer/KafkaProducerFactory.java | 99 +++ .../kafka/producer/WebKafkaProducer.java | 101 +++ .../kafka/producer/WebProducerRecord.java | 62 ++ .../producer/config/WebProducerConfig.java | 189 ++++++ .../transformer/DefaultTransformer.java | 87 +++ .../producer/transformer/LongTransformer.java | 49 ++ .../transformer/StringTransformer.java | 46 ++ .../transformer/ValueTransformer.java | 65 ++ .../ui/manager/plugin/UploadManager.java | 60 ++ .../kafka/webview/ui/model/MessageFormat.java | 22 +- .../ui/model/PartitioningStrategy.java | 123 ++++ .../kafka/webview/ui/model/Producer.java | 155 +++++ .../webview/ui/model/ProducerMessage.java | 129 ++++ .../webview/ui/model/SerializerFormat.java | 123 ++++ .../webview/ui/model/UploadableJarEntity.java | 61 ++ .../repository/MessageFormatRepository.java | 24 +- .../PartitioningStrategyRepository.java | 36 ++ .../repository/ProducerMessageRepository.java | 38 ++ .../ui/repository/ProducerRepository.java | 42 ++ .../SerializerFormatRepository.java | 55 ++ .../repository/UploadableJarRepository.java | 57 ++ .../migration/h2/V3__ProducerTemplate.sql | 49 ++ .../schema/migration/h2/V4__producerTable.sql | 34 + .../templates/configuration/index.html | 9 + .../configuration/messageFormat/index.html | 22 +- .../partitionStrategy/create.html | 198 ++++++ .../partitionStrategy/index.html | 116 ++++ .../configuration/producer/create.html | 365 +++++++++++ .../configuration/producer/index.html | 82 +++ .../src/main/resources/templates/layout.html | 21 + .../resources/templates/producer/index.html | 86 +++ .../resources/templates/producer/produce.html | 90 +++ .../ui/controller/api/ApiControllerTest.java | 121 +++- .../MessageFormatControllerTest.java | 18 +- .../PartitionStrategyControllerTest.java | 586 ++++++++++++++++++ .../ProducerConfigControllerTest.java | 96 +++ .../manager/kafka/WebKafkaConsumerTest.java | 49 +- .../producer/KafkaProducerFactoryTest.java | 73 +++ .../kafka/producer/WebKafkaProducerTest.java | 170 +++++ .../webview/ui/tools/ClusterTestTools.java | 2 + .../tools/PartitioningStrategyTestTools.java | 59 ++ .../webview/ui/tools/ProducerTestTools.java | 118 ++++ .../testDeserializer/testPlugins.jar | Bin 4948 -> 6892 bytes pom.xml | 2 +- 66 files changed, 5570 insertions(+), 1027 deletions(-) create mode 100644 images/webproducer-sendmessage.PNG create mode 100644 images/webproducer.PNG create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/requests/SendMessageRequest.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/PartitionStrategyController.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/forms/PartitioningStrategyForm.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/ProducerConfigController.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/forms/ProducerForm.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/producer/ProducerController.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/EntityUsageManager.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarControllerHelper.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarForm.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/KafkaProducerFactory.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebKafkaProducer.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebProducerRecord.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/config/WebProducerConfig.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/DefaultTransformer.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/LongTransformer.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/StringTransformer.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/ValueTransformer.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/PartitioningStrategy.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/Producer.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/ProducerMessage.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/SerializerFormat.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/UploadableJarEntity.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/PartitioningStrategyRepository.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerMessageRepository.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerRepository.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/SerializerFormatRepository.java create mode 100644 kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/UploadableJarRepository.java create mode 100644 kafka-webview-ui/src/main/resources/schema/migration/h2/V3__ProducerTemplate.sql create mode 100644 kafka-webview-ui/src/main/resources/schema/migration/h2/V4__producerTable.sql create mode 100644 kafka-webview-ui/src/main/resources/templates/configuration/partitionStrategy/create.html create mode 100644 kafka-webview-ui/src/main/resources/templates/configuration/partitionStrategy/index.html create mode 100644 kafka-webview-ui/src/main/resources/templates/configuration/producer/create.html create mode 100644 kafka-webview-ui/src/main/resources/templates/configuration/producer/index.html create mode 100644 kafka-webview-ui/src/main/resources/templates/producer/index.html create mode 100644 kafka-webview-ui/src/main/resources/templates/producer/produce.html create mode 100644 kafka-webview-ui/src/test/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/PartitionStrategyControllerTest.java create mode 100644 kafka-webview-ui/src/test/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/ProducerConfigControllerTest.java create mode 100644 kafka-webview-ui/src/test/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/KafkaProducerFactoryTest.java create mode 100644 kafka-webview-ui/src/test/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebKafkaProducerTest.java create mode 100644 kafka-webview-ui/src/test/java/org/sourcelab/kafka/webview/ui/tools/PartitioningStrategyTestTools.java create mode 100644 kafka-webview-ui/src/test/java/org/sourcelab/kafka/webview/ui/tools/ProducerTestTools.java diff --git a/README.md b/README.md index e322b92f..71c61d8c 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,16 @@ to the client web browser when you're looking for a small subset of messages. F ### 5. Define Views -Views are the last step putting all of the pieces together. Views let you configure a Topic to consume from, configure which Message Formats the Topic uses, and optionally apply any Filters. +Views let you configure a Topic to consume from, configure which Message Formats the Topic uses, and optionally apply any Filters. + +### 6. Define Producers + +Producers let you write messages onto Kafka. With the limitation of one Producer per topic, you add the fully qualified class name of the object that will be represented by the message, and individually add field names that are remembered by the producer for easy message generation. +![Producer Configuration Screenshot](images/webproducer.PNG) + +Upon sending a message, the Producer does no data validation and the consumer is responsible for affirming proper object shape. +![Producer Send Message Screenshot](images/webproducer-sendmessage.PNG) + ## Writing Custom Deserializers diff --git a/images/webproducer-sendmessage.PNG b/images/webproducer-sendmessage.PNG new file mode 100644 index 0000000000000000000000000000000000000000..2896a1046f0d7dc18403eb5eb004adb7552c8568 GIT binary patch literal 45052 zcmdSBd0bQHwl_`>r%=mOYbzi_>OrMeAs{M4fb^6KwWzdOM~0{oqJo4ufsmvWfht0( zRuGwr^8^7=!jJ)p5EUc>Lcq*S5kdwCnaK2R#17}a_uSv-{pa5M^P}l!?>^7o>sf0( zYkk-7>@(l(3bXle)rV$gW;WZu`FgjRnI+oH?C)+LSO9l^I%vuO{_|ey?y#@S>Uy20 zfQ!E){<-s?W@ZfpEB%r8f$L>S-yBReGqa^H{(FysynW2fjCg+g*Z=$h9W!Ill}9O$ zh;&kX2Q(AeJ40dmuSmrl^IZO|`BxttEUEwcRzu6@QO7oh|Lx3|Jwu;AM}Ot)d|}zL z@yCo~7IwW}T@8V&&3SQGpUiy!@unxLk75%xvJa0Q{?#FDP19qG$XN;IfaObn_7uLM{n`KZ%;ulP()U>|?#4rBY zb$5SEX6$xH^m|L!UtDlX7In8yz!{BKK_KdkW`?Jdo* zU%LA3A1%vKRBQ6mmD#7yikcO%f<+zye{O%1_++Q7aDtk}!XKZqP&upN6PUqYR^!!Y*~wLZ>>< z+z#uR%|?!1e|*wcPL)4b(`kl#!~%s$9!_~rT|ofZ=uD$p`4cO?H6N>dB%h+`;+hQu z;M1w5RMA1oHUwU8CFJWTSRH+MeWLox61}&32!w(VS~5aY=}sHOh6xLVnu{iHj>f5g zp6mv;+1r+*mNKz!`ffscRgG>B$ne!=?97Oq0W&bA-yfrB6X6jX4(GbH` z|D4pJ#*eG5&EK+NV8XK6KoFKd(KWiE-)M$hzBNs8koMEXxVwPrbVpDejI3N{zG(~& z`w*lN6YZ?q_(o~GaF|5(nWLKI^MyjZ$pFqXDdEi1(f9yAxvPK5nhF#zQ>ezt)Exvs zk>qD0vi}i<7Ul_s1)xM)v}V+v<}f)q*%4uyk0^i{24OZ(?RLa>h7Nn`N4@@{iuy!u zt9*z=eZ(c|pA%2hjAB|MOohU$QS4H~0Msy$^!R=I(}e7CH(>#ErsAA&5@}o)mE8HM zq}wnGHXfB3?kGSyw!A3<>ybT%sU#Q%)c8*(jn(!YQg<2;hf!2#dvF%Ykf&wFm-vn) z>#(DAtU$WV($+;r-Ey2txTKsnw|ov0L7iz z#ybY%=onus0_B@(mHwTg7rt_ws?AMpkI|th%LouHOf8>c599O&a%_hAm9@5=P z{(OtP>Jz{Y&{!^8P1Blt!rJO=&x(b&7T^n`nhece7pNUYvCSp-pqjp)%w;Yp&T^hX ziYll!j8xlzshXqeYc)Z`i=v4ereIK-i%{*w`sA@Lc`067Dj{^{sA&e0X$S{dc)p+7 zk26U~e4cAw;c9g}Om}E;ZUA0j4*CGtBBwAv#L#e9-9ak-54tG}2pUAzu8YSHY4={a zn37`2yCw{=3fx1|tO{fBS^nm-?R`Jzs3T~`lYV0~{Z0)YJ5wg#1JWJUO_cM8TYEs1 zm)gg$pPF|NU6u$kj^>!RpSI$;bSn0?{LrOsw!pyeH@)&cl#N`_J%nUbvwHiRVM8Q z+sg>mK1uwu?6crLPNs#lAh?ug6m(|S@< z@ByV_Tp)#6+rJ#bBN)5CMg_Z5DmfFD(y%P&Pz&CCyi@B;eT{AeL@S^Y%o%G^@3TqX z=pEhUG?n-u+=Az0>P{7!^uwhc<-r-7^)#reQ%-Zqj_Wg*jUcLBwjy~Poa5x^ND~vv zr|s|_T}ivB&arg;pjyWfbwGHt#d4x?koF;8+l$IFm)ZDoK2pi}d}DNLr%!TW4yn0! zbitD{->;Ir!A_PTT3HSqsmvf2Ze((FZAGDYW7RW^;)QR)qMgH>Ddx268cj?qVdy*G z=%u8(2G3IYHWArvy104Dtqz&K-@SvM!FLv?+7+EUJu6XtS!R2lo1A=Du#+dXxsv>w zv$nFWQ)y>Qb~8^%!9+k`nm9QKTN2UNKsHX~%Tk>q+mPu6y`$9wy4_J>n|^Mh)fw5d z;UdYCX zn%8#ZP9L~wGLo2g&!|CmlwT-4&YQ82g_rSTQZW07#> zC?i2*&OPf)U&);Jh`E`mQOjde-e~Km2(*pk|Yhb!zQYO?E+V1ew_u4WCeJAOtQACwTh`mD9&gn3 zmBhdzvZ_O&sADf*#4ytCDcg0=&w%prMiJhYuj8pZ;)#@%M!E=Q{T8HH8W&`2q`eH9 zJo>dprN+O+h02c57!D{yet0vCpsEMWpB75xQEGFFwOqt((xx74{c5H6ZHRqL; zl_kk2>L$D>u?C$vadh4zlzlCXkiR8<=rp~+js#u#Y3X_%OwZ$eHJj4AABIgIZb?O8 zKA{SJvg(LNaR+ftNa3PQ*Tr5zQj|j~b{{lbRv>&%XLQi?xx~}H(77m$X_2o=yb&eT zN`10O+0*gDplWR>dhi{v-*E;+*0jiVv~&cTMKl~ik7|2~Z0C8pTmm+2YnHJ9erszZ zPP>68^Lc$jq!A&{;Gt41ctdNm+{OjX{$4Lvs%v}`SF*}L|HQlw5r+svrcT*$r9b7K zD$7BkUlo}GQ=-Slsi5eQh(a}sgDdsC3>Wz_L znuT_-Xhx-qan@9H7V&p`x^y4{>ayr&@)yycu$(^=cx~$!RY6f?({KZP5i<@Faa*zh zd|<=Zbost!X%}%2mvCCu_)8%|u@bJRw}1JyNs`A&gF9((qw=X zDS)Ks^Iu)HEMCWypK^43zpy6xCv#*?P?K$R;(O-m77T4nawFsv-b;eJkJ1h$ACR`| z+J!md3j7X<^+O69IjGHos5clmk7cYkKXY`$?Mi~HcrInD&hcT8=Z^ zq1lxOYZLq=@XGV*9)gRF69~JmLOI*(lU;ZO#dU*yXj*ti%oEWJCv|H660H2L2$U_f zqnngG2)!tLx=JpvU#ULFXI0m{x3#3Wj_Br=>vie&M7xvW4FkF?!ZdbIsA4*ci&}4` z17T#OY382L2Tc8#Dm&P=#0D?0xl9~VYsmw~Yqx89STNgiqYTk81%j;d%+eg!yxI>t zY1h6qxV^)gOtdREDD`5#!rT@ade-|iMOEwXVdzHHgMT!Pn& zyN+lk`=j2LY!sSZWZ5x+u`F+!Evd(4Y^}(QnN*a#3!&RLL#E7Sj^&9Tl=9L(i!w?p zh2%ot(|h*AfVtCO4hOb*lTRpLCv|lq?d&DPAWzy(a7+Jdv^6lIMd! z+NSz)oqPFNA!zna-_wnwjNVB5U6LR;D!%#6P1pzarzoBYyhd=s@Ph`mw%cZD_Tej) z4|CM7NHZUlN?QDT?!UkXvR|K)oJVmZ*D2aE-z8jbjy!Wj??*1Pmjr(l$Isk}R$S>} zFdoIF`k5+q^a&Z&^Zubqt>)t;b7OWn&OKHTl&+9^k;d}9Qnjl3g${k}gJ?hdp%`5B z0kzazc;Q7iex+=UbEdvb-peHSWADjsFtgX{3mLF!sxm-jS3u8!*cOa&eeGqJlH`4e z4w~QP`8WK$Gx$!vrcK_P%Per?`oc=Tb*0||`F-TcFBjLy)6|8s8a{9lAQYF?u8ykexy?`ha8l^c?7-Be+5coR#4XVoGRbx zxxa4vWffq)9POOH@R?1w;K6je)h|j$5A4Vuuly;8r1w4-Ko=JTv#^cV^9lz0*e3`* zD_k>e-es2DncE%Ez6Dkpyp^8Ke(Hu@w}|Xjy|2COJzqCKd${QE=HrU|$K46dt9zu) z>nh5q#IG-Uam{6Y@|u3;N*5P&BrIL?Xgi?+u%Re_hS%LhL1m8tbnG37^UK`T(GGDM zk_3GehvI(hd7xUW>G|{sTsHw%=M|+!c1+P}@hC&Ous>Qy=kO|p74wF*S59}@k?G(Q zdVrlyT)Rh4x=GdVvyEuPs{|TN=T9eH#BGehZ4Xwz+?3zk(+=_KS_wyr~PoB(p%^Gw;7ZN9m>B8^StyzGbUJ9OCL|9#L76Pqm0QbuplQ zlv9Y|Rs=&FCy)^GLzMX)(&lp80!c#yG`Oo_tL@F%EX=h;>D;q=N6XR9NNHmz1Hjj^ z;Ix9*=ZXF<`yi}7P#vUtfY$4G7K{_PknK8?wQap3TNm16W;chm?V!~ATt4xZkJOfm zjqsbd>x2?wR8vxDX{H4d<4{~Z;BwNETBXvBp0W>M^*L3rK@s^ZzhH2dz@?R1a?1|T zBh^GcgQt~lc)O%8%@_4CA}Zi7QD=u z-pu)?LLG>>A(c|tFavk6u_1C1w{Ef`&y`m$PfPo#XeC6hs$O1h-H_KKiNn*9Qd&3+ zODW?kbL8Y!NPhA5$hnk$ICRNv{B{t~ILto}C{^r^nM;xMJMmqCg{Y`|to#AieH%n{ z3z#gK+o|o|ShM}WtM+my&&YnJizB+QAw`hdzx;tlRFnLOI~w-fc=*7+#ACZ`2sh!L z)#o0JAXbB81um%-jqo&5uVKBf+|%R`nhTG^nC%Q3KJUHN@#MvvjKh8}o)_IbbG|RF zX99EWlyS^l4bRCz>9MZaXQpvn#EN&Y-0bzP=YUHntg}M0Q$OpGw0M{>XvQOBYyBfb zrf0PQ00uu#aQODCbauVwnFJ^4pVk$az4VcEm~qak5}| zh;e}(&1pPx?S>s$qJE)(pxLA{+h)l>eI3ySv=VPnMA&FDEf@9UyPWoKk~KEu+Mxs+ zd&z5%CnZHZ8p<0K(_8j)S577luk4L_-W|XkShF!v;PIqk(^kK&p6LRQPe_$ij$9xq z+8;JQ z03PmG@sVF0W5zyuy`lJiWw(k4^-9U;>K`>|b^DGAJRXMejHhg&aFWlQO2!!%A`v>9#{A%K3v-S=8Z&(T!`P>f!jy?Bo!?_??quYvZr? zgik*Bx_I@H%^6+{zqF@KJ{ za>1JX=g7@#9npVz;kTRqix=Km$U()0A83?J)+;N0Pp7i#UclnMny~%xFRuz8v?2SH zcVzD8eLSg>+Lgv+?)8(+afPRRT|S5;A?-vl&6`4yQnPRVAff+Z+MFdX=ZIQzWN^n^ zgJSFHR8N|B0yp)u1d6IPsrPgNkMrIyf4t)QiK%fL@>G4BscTLg3RrJ0 zyE`J4u_zgleq#OFK&Pd}mUxjwP4%;LwZeH$Y{_?vq~9dE8%3o{lYh9%;XeXy-Mi=| z&)5)?(V|P>gcB>Q5OvR@)|JF$++i+M-dbG1PFSbOF;m+{Tq5$sGJx@STq~dCps;?| z98MydPM5_aRuFPk#`-pZ(X%L&FYeO*s1h^C2crL~A%L|*aze}v73Xu79 z9{^EU$}qN=xC~*~p*Gm}<`wcD0q7(xy!lQ43*n1$pGo7Vz>Kb_~s4lM|iI2ru>j9O$pz) z$nt^G|1cCV1-iDh0mMudEt&puVkqCI0Csmo22f-3f0Gn#-`h%7Z0bg~Wkeo$bQ29j zJmqG?5l@4V-29}j{8LBhbwljV@GY=@?48LidX7s^L60-IbPXLA$c@aJ!4mHu#MTi+xOkZ z>hA|0wL2(anK_cAAOWI-FuS;3ZLxBcyo$87B0#PJxczA*!VuHCsHkP*@b0(z_Ma`D zeJ5Vju??as=te#dgp<&rvY|=||8bl0wYbCIzRpFj+4P?a2rH00%y@&6CORxslDU9S z5&mZi^8Ge|#=&?tJ#qFNSM{>(NNhg$z*{v2|LbDzW(bOEW7uCPjLS+PW*4qcb-%MP z59a}s@dF_S1Z3vF_|HVRsF8dAgINEc?y;_0@cKbT2>0H%69Cp9mxc&7M~){p1Q^pypK+_vakXZz+U;f&I!>(kC72_NTjj@E7PX;oGE z2TdU!Qe7>1qYgV4#MwbXH`i&=)7jCTYUE?$i;ouV{2hxwY1_}W;HCM+L(AoST=pBs zvxemj18?d-t+OE-WVTYB9ub|b2UBTmEsC8Mr}y{pb04@)g_8XwiQ#a4}QbO*fe z7LkdA?W|9`i|P#QFL*3S3`GYDYZJnQ0`xZ+3nJTK|Fp<5cCT$N5KuUm*Obmm`M4Bk zy9`gCY<_5Qr0lY-i?|2dqByKhEpV+4Zz1OL38PyJyKj;zgDqM~<-xWEbw~kxn77_` z8x@F(c&y~L^kd|N#M>KA@|i;K2e*~V$zX&)d{)V5;IW3CgaPW+E+-(LGJH^Nl@`UZ zlVjT|+0Qwur$3gqRVJ(N?Gd$xLWw5I_6?rC8pFn5FeO2kkwqwzVqG0GES-QbL3jBk z$w!YqqxN<5v~+FDCEOA+(vvyCA45yXV3WgBmw|l};(B$%XvPBn-ea{LH|~4c>;O>{ zz<|0gur3z2=B5+4AG?Cw)ZuTJe)%s;H~63}>kvuJC+tF}j*qzF^TTeXIXJC))>;(2 z0S!RcO`OgYQZ&aTO)rX(HIE)1G#$dw56Bb)-u2L=A|A0%yvoCulO8U^=&;XNEFJG0 z24>0%3+s6-3o}Xfg_y^vn%J?yzC4@`$3#W-pY2}DxYb;Ies(LRM=*9IicYvVGZrUk z%9n<<%{O%!6km0YdChNGDRXkIWtWzjnmgaiFv4G-Joa-D={!#QMmMTdgTx`F2Y;7i z^qnhPY{N%R&Cbw*vRxE5XAP@UuB)(zdRQ?(MAhNT z^y(Am2|3s2y|<`RU&G>^*ICNK?l)KUv0uQp^4}yoWRmBnqvG^qC+2nD*&{rb-zNDa z4Mb;^>Tq^sWFNt9(bycqy>)!RPyeeeQ#tDEO|NoLhrPu6&wY8#?cjX{HRtik!vZIL z#c1mT`0ivjp!IMkmyXe&9|=V2E+QJFxc2&RC6TD9cs$lm^c}rH;yXROI2I(QqS?=8 zzm>uk4BvnUK-Lfz8v_YyG8x8diMQ|ocBWh=Qf@a#rIgLU>zb=)H{}uvl5L>79uUfN z7(Fiy$4hxb&G?wxePh1_-E0YUjW94q-vC-F3swgE1_uYI{S6$BE)(Yf@)s(6`m zInS`_bDV4w!#gs$<*^Jgn>nR!z(OdD9U^oQWM9Lmcoll%xxmDH5SXhgcOKIwk`#~B zpj(~EtF$g;Nj^0~A_ir_NWIu+_>CsRw~6@X8d+pAS1a59y(y~;8kY~Uqa$y5%m*!^ zuIrruEV!Pvh=zAYzgUGXT*!^R)wlfQ;jd3(jdF)loPN=K1-wZQ6CN&dm3v2-5Ri} zi%{fW?DlD?6Se21#3s-J0;4y>2k)uV*RX=-o43;F}sg3RfR z&AjSlnJk9iKITQiU$ZefrxW3C9IQ>*4CWaqsz8grUd&${^25$Ql@eI@&DG|}MH9JZd%t-7pW+2@`@kI_GuHzPZ?mHS#} zOH=-H{FN+-w&&?p<#f(Y-vR~rAXMJ42Oo#^qxjBe1Q;#=SX63$M7+0AI6rRaUjLr~ zr^Tdg&j|1UAYaTW8#mbZ1hf}{2Y-b1m9j=b0wuLHpv9~ov}G&<>Ik)bW- zpKZTFJ!|s$$6L8xzfr1dTe-bIEgdMC6%rS7CaISXmQ(S2{7hWSj>(ptToNna2h4Tx zY)eM?A$F#;Y(d(U&Wr#_%wNdFV}CquvFOp>@9;J%0n%C61BzbrYs?OAQpja)X7qte zR3c_XS>CUX{-}5Ofo~5*aW^S+j=E_v{yVX-)TpbbcwK~RW9}M$Z0%wy3lEsG0i+}`ozxvYe(0H+r?eNswEdhjYY^;T}jc9dm zpSuj^!@^BXq&u;cd{Rc_Fj+KNKdB#2xd-HCd6kaX@L%8I)x}oA`n0zj21s3d#AIp7 zV!PAbO)v5WE}mD<7eP9Pf^>9t9p7;%8WVo`ltB?`8S^|d+7H2|dZFk|HHd|h4etc? zS7Q3x*(YC2COx*X?1*KZvO>mOB;(`u_#wpU!k=nWh4-y4mL>-y_Z&!#_Lj!@qP4M0 z1l{AbWBw6{6U6!FxOdWDqdEAeO1^1ni&(vV(wUKeH_~0rRsWDs3 zjWq_@nyqSIM9Ob~@OaxjAUr27YZq#vl9b8YBF-z zbjPXg0C3|{beb&KSy0!&U14884i4it`vmHbs<|E)oza%kJ+h`FDL^%8r%#Z%tOFnF zVr3_r#(AvJ5`?=>-;$O|xRdRtfaKk0iva^bfGxSfO4RXMj0e)<{vUbLPQZp+6-^nX z+*es`h6^v)^p<#^G1mA%XX0H`=gvj{zTe<20 z{AaJ;hzQRx+7@>@T}|t|F zJTD*pHE`Fb74DO|OuX=XIh*#yHO;V`*!}rEJO)9ig6T2NX2;%Q|Gfa+<4Y0VgavQn zoCTc7fLRZPR$AN)zI3!%lHc9$a0+^rT$gAl145hQ|PHxd%4> z>2AINx;Z~__(4Lu}21Wh_JV!v-dVDsqXYyP=+VpHyakSEP{(ly}Z8aVB<*`dLxBh zE6%>Fakqs%PAhKB8g*;c7!-5EVr}M+WL39rO{VBx|BYJ{+X$ z;B+ETb+^(JCV8pINmV}WmU~AdfwR0W5pVDj=2n9OL$7^`cg)@xn6@)pS1kWpRr+3& zXKK27lg$5<7%3I?L}5uEoNeWsj;YiiR%sNFR#N)6&C2ieSuxAN&Yn2v)Lx1#dUp3k zAK9?IGDfY;I}6(!G*JSG~WdNrhzsn0(l~6 zl7~k>F6GYHwY6B_gRK~Zz*WKM!-$mZvy46r-7=Dam{G&u14HqCfiGKod1D)074Bj9 zp~CK!Gf|rngt?di zXu+*Vu8dCR@DRgz9YYABt1-&$pi1by2^}n>4Yyxhgh$IWnJAQ*8w|c$5wrKSOQ7tpk*Uw{D?JXM5fQiHYrC~0VDkJi9r+Vl zfuWmV90?$HR=HmtMLUz-I19xZM7o;4nuqra^iJ!`J7;uM?ncju#>P-hN~#uj(%$43 z^QziSp~rB#tFw@%co$sDn^BRJIyC0y?^?-V=L+v2Gr%h-uLDRi16j_GaQR5X_}@$8 z%?|wYPbjswAV+;?T>aAK;RH|R^}xYgtxFOwj5WgZ10*iBM3mshM+3UK!{|4LS#V6@ zg}#YlVLiSlJtF|pgB3h|nNaTklVr-A6hQG_J?T&$C^j`_8G+&+gG$^Oyu1pHSC8G3PGj{`cGnsBhdb5Qu44@Z zR;&$~(J>~gltjyt=!BXTVW0|;ZnH*lsZo=IGV1njUeLrmJtKc*S+`2joUkP+0WiWc z8crWu7^li(9uWx`Y#|J?`B3Sf^H5#eTzXjhB2T&|LwkpeFu01nx z(g@Uo4QBGJxIYnpOO7ur;4^Zu1#d5+U|?S~NE)n<^5ZRpXeQtV^h((q(~U{J+fi5+ z4UFr)neggOfeQ95C@8iC+*iBziY7kFFHpBz zOl_*u<+)kXB@`VdW7u?OKJ}tFk$bC<@8_ff1%4d!Ikk6xao#Q4b-A5))%lD8Ly$-vJT*oZGqkUT$;3Ux-C_B05|bC&-9aSuy+=(XSJ~g7FL0>*5P1Ttf7Fdh9eU*(Q36TA522tg7#Ns2;^6* zpXt6@0R1gt#NPF1XaSM@Nfq{iau`<+WJWCO2YAV0=r>OrdL^{^D72nhX+V6O=(rGW z7!OQ3H0x8MPoTaGxM^69fT8cJed>qDvaY6rs{DsdIAfMrjKtg2j=J0M3#y?GRjyf+ z2Z4Bda0uR_-csRGXHXrzbq&&SAqG54P9J^Nmc@E>M~TI(whga^D`$i{ zI=7$|pZKuX9@V9L)e0tn#HJ`RCxC{RSM@g5Ut0^hbxrLq8WrpCuAdSlcGiY4o9+PE zIn-5y-*JQ19w~~pq&IRIUEQSdDyd^=%GTJ2^~ zgikWhb7&WD5bkjBzwOZ<^l$Xx7`{o~*veAS_je2s=4p1fZa`O5J+^B91PJ_g2Q5}@ zZr0raq4~}0IogpL+GMu$F|YA!28ArE&mo$wa=@)wc*ma`czPn+TBo9^>Lk)f`2tE zOODK9)7_+f^2bu|9bqF4G5#D($zVIoj5H6c${c8i{Yuln@V@p0A)AiD$;&$7w2}wr z$hrRh7Pw{}%gt$1-ya{xkZ;!RDx=iX>ajk%1p`)NHO+|W>cPL2nD zByFRsL+J%YyAv;k^;8tB6YQlB)HG6fV4W3~HlX`OS9TJBb1KjZE2taM%f#gCOBNK~ zJc0J8rh;c8_?$fileL##3Wh*~R)#~79t#IH&Kq1FJRJL#BlD(n+wmVX+*fpdHHqhx zngCI*)mL279tt;|7ye3LzdOn z5rO`kz9h0*{Uh;Oj<2rtWjN@C1=Z-yT#y9m(gfh(i|nkiXmiHZYZ`1Om%vomPa0^P zU5sDysJ$h}{w^`)Df*I7`tXIe6nN;I{k_ic+fDpZTttCo*+1blMqi(f-YFs21u2@; z#%;Dgo99L>_-*KV+ABdYG@~P!Fx!FxXg0pNQA4Spl5IY9DY&Ey=dsq3H&#tLV zM`Yk3rY#CjMTZ4sq*s6MtA{MJKS9lDz|$uERTNo-*x`lR?9DU`G3hu&55^;r-#O~; zFQj?x0+F6&3Py7fOjIm$1t6_(M|2}}Ppl*GU}WFGV7*6B zi8Hh37iCA-m{+N>;#WW9TEa+gV@E+u`fs;PpwcIZeJWF+6TFYHzJ)(#ByT4$9`xE< zmC2aY3H`y%y`%WvZ?TJe&|Z=5It3lbJPIhBpMy>}F)(&Xr_TG{94a%X1t0b1SC_0c zlGR9Jx&i1LU_RpBuedBRg;Ab?Q&VyaDKJ z2wb)3duCw0p7XQc7it$2rDZ+X^(by$Pi#N!bj)p@ve;B*8>u+}gh|m{t>y%chqp*a z`W+bmW$zo>-^C*a_P13w0r~@3!ab;CIN9iq;fJv|)fo%^z&^QPj>JESZ01Q~ai-}J zR$ABH8<&xXqmE}MM81i4eQ;YmbEt>O8fNIe5)~(dV9%4I-^9b7Xya?f&Kooz6Ecq^ zGd3abKtW?3Nz?-Z@pGKblxX7*vf1-mmzMw(Z<9fsV;ojhhT+}CBS+$x2H~tI%#@qE z*5y`bwjCqtrs?v5=1we8r1OFM<5#|bpNArDP=|Iyb!$zWsB>B>t1#2~;l>H_K?g1F zI@prhzQO%hZKjMLGihAQ(2aUDQmWfBO}W4AC*)gZD3Rcd*PV?=Zld@=^tfZOcJ?kC zxk;X}aZ)UUP2QEiRB%zd-cZMpc<<`+sC885^gA`j05-g#eH|x@y`p3O7CEn`otlrW z)LoMaWYK}M+Ho~-^z1PDswTQ<7EEIVE^4inA=Yr+vK&PHq$+EiSC0`K+}X)WM)$!r z+4RR(L-V%Te$ux)|7>SqTvss9J^piY*^NSlU;$-b<~1KqGF_R_0E@Pt33`Q0LR7 z6GtzORk@BCWnaf|U_4QUpXyjM27~|nnZfp1g*HFeXgI_P-z+nZcR;ThN=KrfgeST) z@H63?Z_!e<=FdL$Op_iMZ;9oNwZ8Og{F&{5UZy_p{_uu~Fs@4-eU^k#XEA8^#lPLC zyr+Av&pt=`n>q51V(6C@#M8GxnMjjkF`U`r-q9Tf92>-dQEa?5SnGE~bT*l$2-1#R znb#hDnT>b#09fDYq)y#89q*xRtR_3FoB5I}IOe)7@|y&HX3SOXtU0&)ek*E5{n;hc z!S%iF1~~anIxHWAipBGEq*86Ljf-qFmNfPOb@>{GuAq$Nw7W3N!}p@c$Pz zzOL3iWm&?3%tcU4=TP)VXS{Bx@ZM5QxY}=0m@rOMjiC!IbWqI{XXf ze(ZIYzsfNxdg`|aH_`*~!&v-i9wDPz>0-L}*0&)OVfQ0(EseB`0c-h1^ z1G0z(h`+rts3Z>%#kCZ`oYX#PkDMf5Vu4629ae&4ewm2+BCD?*wd_NyawH4XlNPyhsmYp6pxX#3A zGSqD8ja1s)arDBC{TxUcv4t#Y4@wX}*bp8RrZ1)zM5-nl0;UcK z3uE1CbxYBK+n`yS$x+!YL|3Z_Y_nG68LR6Uxz{U& ziP~ZFw;0Lm5@mVGt5@d-gCQyi^+ z%myJ3!NzjK=(9uWiIE6bsq3wy+22sB)M<&0h64c^YXavt82IqkDRV>Vy-L0yy{gGj zCr{EP?{mI21fP-T3WtUaKlgv1EqKrg*HSAfrK}){sKDY)Ua!ldk!-$cJ|8LlfB@Pc zWu2D~U*w7-d$b!Ik-T~!TlA$`3^8(Ql0=ZWjuU`^xuHz$$8=4-)YfQtf!R!ouwi_z z(5Q9VJt=~d-|w}*0UYum9X}(g1!z&oJ1&}dqK$Uw;;3rb5s89Zb zSogZ^m^dDYTE^g;GA(4Am~P0+BS2}Bgm2=pTZ_(Af>&M$#5_{^VFbr#eT-4)nLS)^ z-F)@7v5ga=`%e~)Fm0`&?2DLH8hNTjaYPn%5eF^VBnRNRLn{};CcGHctQ668bd{!G zP^$=8@CmA(#fLt!K6gFWl=IC1;=WGdP{giLXqMTJm;NuK4YV2}D7Qoi*)~(a}Wl zlT=H3tvOAd+&ofm+C#^emBpwH!W?z4HFg)N3@dCnPqPFvrBtZnkBv9@ZSPx@ziR*H zqwb(Ro-R&@UML>?X0@FL-L@apqwhop)ZeIW^$*l18P~2CVL7BaBu16&7D(!Cu77E8zJ|dcQigo~9s428Yysnf^oPEs9@k z5T0pU2uZ0MXd=0~(V3i$Tjn+@n{A5+8+b~OY5iV(*n8TsNDhy#2)s9+7qJ$+A+Z1D zguOxGd;JNJTG-$t_f(R%WXCvcbR_rdE}jsS72GHuXpj##SJ#dzf`R_a1O3{gN6d+y zAB872au($_D@wQni@&p&L;!LfU6t`?8J)syhL)g{If}2lyYV>IWjy{(iH`eHaaqm0 zT{5qpZ34y5M(&?m^rwYD^R-3Q&I;QXAS;b^?azSn;qNs> zz>x}d*wK~{p;XEX^-&JyuyPqT&c+#{yCdmTHRSD)1>t1xtW*aN$cZ9lWh zvTbU`9Gt)?A5Og%(lzTwXeyrCYha9j1(N}=bA}P_%-UaVWk)kD@RI!{qhdNYA_MfQ zFc@!RJhze|9MV-K;AtZ4GD$~xXJfiLuSL@$sUE}%Rk>lIQrS_wG2cT87=TNw(2$iF zUcjtgsG3+{B=qad_fC}%D}da&3}tly`&hbX+vX@X>+@bV6eZd8+dQz0l?9F zA0j-~sg#81SqEq8i?ybMn|Gb5ZoKg}4A&JyM*_e~+^(~-F~H=E>Z3>b%d;jS5nsRFF;#>&Gsx7S>H7zjkdGXtW0Z9ep)hH`jWW9kb_#z^!oelYnl{g?SqiD zW@dZdmPD4im#oPy7Q9O@P23hFnpvZ7Y%x3OiCvoufwHmgW@bS0{Vxh>Yc3nF1S&)7 zgYk$0NOhE6=KS~DMM&3aQ}6g*RGxdqhwqu4?EI6Ex6j`0zdR<<(EZXXg6C9MOuHr* z+3lR@4maQUys)4{Vkxz=0|l>&IbLZxn=0rgI$K46TA>LtgR5{)#~?@v+4i2m=7bI$s1<)>3c7q0W&z$4-1JOvh4yLT=Bd!ADR7D_h*>3^i7a= zg*?F9_rF^Dy2pWlzhbY&g5^@h@%O6s;xU(!-oGvZxM3FlSMwWl_(HI8mJq(d6_~tV z->si`OZ_KIaf`R_Vhh_3nHIp9bIALf3EpOA>sGs*?0%bk{O=sW8n;;N<-WU<-SPTf zMt%`XW-G31^}Ih)zZyTX?9%VW&K(zKi!M!iTbQ-4xgK(AmO%B-DGE+3>qnhF|+js+wB_DA*h;<0S_jm&fJnRzXn`Yv+o zBA>6JW0*xPEhSKSOtZ)0k)x=T6O$vY_gDp~B#Bk3Z-N!V7N78M9#JlK18Kie8BV+f zYqR7kr_@xBR6#UQ{{=dGtJs&d2S|hE^!H^v`yp8C>WW|?Vs7&HQu4nL^Y*(fDImhz z8d#?yE3xOrBXSOE@@##wOj@6nBUJTttMY5xR0S;*9sS1s-aMauA~5OIQl)I?F+orY z5>0nP)I`R*kaD|(h?f5F-)y*4Y(y!czZ>xQQL{S$fs@n7(NxAn~Cjum<3-vF)|zoVFFdy2TN7h4bZoRHK5w0 z`$oO~bs2xjXNkDjU793Uh z*6ItJnccqTkB*CrRQEt(7#Iob)Q!NDh=tcRr`BYrg4S)Rn zZLjbneUAE@W{6`_VJRF(caqser1PwWoniD08s2^tUDNfg!ol3^@W=f%O$mR|fJ+W& z1ih64G|XD1f+FpEN1DJr691QpGf&%8SCXa{+W}HQkzc)|JxFeA-JA;t4uooWW6H9c z_5YR|XL3+Lw{sV}<9&MqZsuHfqHQEG6XS=Fmegk^hWnw|r$+dXFkM4Hr^S|3;j0=o z_|X!-yIs(h7M#%=Wd&8Wx>rHBPD=>nS6HjtkTItXFW}Z@KW_h~;Pr#QsNUYA0?KC< z=@XYr4+kSp`K2r07xs)q2GFI`L+to`&&D{<`?O?O^Ktr1-Mg-Y_!e_y0B|rjl7!A6 z@@ZB@|6T+(WaJ?q;5^uCP*V&h+7CGpwD611X}aWtTpic zq|Imk?JJ=2-T{FRdVk#?41aHTm+!tS3UDG4de zS1$YCbt!|rw*OZ%F7EyR9=GU*?*^Lx7f<@{9?_{;SDOZOt3>?=a|>AU>(7_Y$?UxN zuk87qM8K!L-2C4*&1*Zzq+#6n6gWXvvf+Qi8~$(c%Ky9Q=mz>UKD3hstmRZGrXh-ZQ5LI7_jWk2_Ve@Q5L%t9L#G;W6y?VtYf7k z(RX#Y-?IMQ{(7r&9HDzP0yW>wWjP*4oE75iw$k z1X&+rLsg}dF@%AvI>m?&L1sg3h*36vdnNwOH$P-U;lAbR_)X?~531j4dZjBL-yVap z-dXr#ur5kkUa zS!r%Wq8+Ae4!qK}+&v#9i78Q4@Mv&6Y4lWQTzhv*c&utn-rp-;@y)+C@==S-WzqXl z0&3jD^i~eY)qa2P0#=_RwY&M6yx)qQ>{5Ohp0^IXD@_OCimzbIR{-yQ8%oJkh@-ap z=8+_gzM}WZ1!eE(g`_5JP>HcWYg~)@Ye)6dQGS4}%#Or^+MlPgmirWkJ;q`r3qXpJ zdGGM0v6D;?hu8mzUq$Yh^q%#jz1K?nRXg^VDS!nC&$6ajHMtla2*dB$kCC*5i$PTa zkjm>LLqIpG0GcG;z*JR)@6n>9(mA3;%bxPmZrlD?3WTEUl7zQtgwS)I&jTFuK7Iqr z;Vy2MblQ4Qb&}rV%=VMJ1g0Nr(Yz&CwVy-A|BS3Q;z< z9Fu$^!NP?+`aBOQZR_gUX4)qfyw01kGS?GUE{l#@NEVtQvc#G~|9FAm(w#?dnbG4xMUYoOjJv$l3Lc6~(4vOIsBnCTF9*A||H8`J zbU)&W5!WCNZD}0i!4Uhjoq*l1g z$9$3m*=|~pNBo|^S%ALm7^aSRW+b>^F#zth%lk*pxvOf6YJ)T3jKsSMY}ou(yQ=L~Z@R@ETu)6L2&e^F zXHrm|;u^48AX78JN0nYdh#l0&a+5%eHK(reGAIvWEK5N3N1 z6?b(nI_AnJyB2evU>jvG&4U4fvw?J{Z-%}Hvx^hkQXMxqI6ej{RMe&2#O)Rrf_i0< zsE`j@vj{$jP%mr?iGXzZGgwk>F53lfF zdQ$fI45rRL*>7<+ihp96`)np;(pi^G(1U$1tuw6ZI^R~qT?*oB>sZ&Y?jzK9k3bX9 z2ft2_bbI6V5PZ*R^H>G#`U%euud>1*jZgX{W50Ik#vhD#rhF!pGAz>B`ZY0`a6z)a z|JsGu*__{0#oY(%9aFsf)8HKoiSCJ)mpVIBZMfR~A+h7h7=m+xZ%`w)25Bel-nrms zuT_P!aihteLwsc}8w*ALI%J)X+!d3POFor@3TXWi&Z=ZIA2gGM_*1!6QnKuDo8vAH zA;%o4tvjhC33Z61$b zl|C>lRGI3DF*?U7(6TUQ{pz4)$NTA5*84tKxY^%-e2DYyN1%WdJ~HM9P!W~6z{2$s zkCwKCR5Xs39VM%N#QR(p58BWn4N2D4jS<-anu>~~#-7$Bn|XKLN@nBgQz+WVy9*^< z51n@ffC3Tl_Iam^M)1Bh)bZ_|IDrMXl9!ZJaK_%UUA$*qE{=y7As&vOXCR=sV!Ko25r^S%j^q0= zUrX`&9_&sAC;JTi*}}rYtPTnMBZfF?o!c&=HLgaNV*@>BckTR}2j(~CvInGh56lJ^ zr^Zez-A7{`dS&3v!V4{V&9Zy%EN0DyXp9+2=ZX1n`PxCoal(D z(=ho?yi|SHglN@tipDHn7T*(E?%f=M3C`n`L!)^(fN!dn=3{y_ahj9m;oMeiiHiGv zq{?AVa2ayk z&#BHoFIzC9w!k}VBgA%Dna;VO**%{o*g#5^?ZPByv{ms-rWnEnri^q=OFqhco}O<^cX|z8 zzHcN!M3fvQE}$MlkS0X^XPMT?QkLT!dt$MSRyH0AYWk)poMry-JSA<$=lTE?J_BX6 zLZ~qwRm}%P(LV2auwsX3EI_+Kl@Q~*mzf0j4!Jq9A&;9B+)-W5k?MMf?Gba}C9NSi zW&h2}By|=WyrK;Oel_$ZwizEzYKW3vCYg~~wCm5?h z4BOUr&*jvm)G%95UWghyS(Q?tAa}g+jz^}dnW>CeesmEzsxmz@8C0MFXji#UGDlq6 zwa7!-?cAT-{a$O(xsX^T$oK-ocAH0;g!Vj9Zk9p1PTqJXxkoj)Z~-kCCF0#I^^;5I z*rVp@OL;#5Fz)1-vF(kVAl@ADuq&#zAFx1|uA8L>DqumkAo-m*448E8Uh)EJZ+hHe zae@npWS|OPPIr5)|A);HMUTO&3s49SQcO$SD8fcq@+x|px1rKYFk>f^E`jA_eASB= zKQnY>)Hgty0WtC9T(U7k(x|-Y_NCAP|!`HF;B$>oB>FSK@+9M!{qhOh&xfzQ)Xwh*`~NB)2)Xyx%;ySC8ZdUxpuKc4x=+d=hzd3Tvt+Iw@C z9DI9zedem6O&;ZI(Y?^S-??8>d})P9e=Fi&l9PMCuwNQ^0+;qq?MH84co@0xy#F3u ziGPxq)3!%dHInZyqVVKDk$;QTJsZ5Bb<_PFdLPtp`p#IO!MGzqWRcSmHV7rmTGA@+ z%)8_+fBQi`NW}w3K5p6T_oNi0K-q1~XP3KsM)bUM#aMRBc-h60qrZlNlq(gCP-z%inF8pbWeWj z$lp5^v*Yfr3PEi6>=d9nvKulxGQV)Wkz4!!h$K`EJ1l-~>eQQFzb_foNi`r3PR7O;kfZSF zXXaKCCr85X;ub7M3!qt($yD?Nl!WE}y5QYc(}?<;`gZp^ZCJN5dZ zslbfLbFV+$)N}n8b%qeSd6ICRO$;{nEFhZCvZrbCz?g9cxB=qqIL(e9NsL{2py!<{ zKGUT57mjhH+y&eOHO+;$5`pA)#{oN*g5BOGnR6_tFyIDufHLE{Pj3Lu1sSdftN|CU zj+iFA)qcjY02)kWOcVG-ur1X8j6ipi_!=?P!VI1J@nPmmI{2l8iOc(|ncQK^Be3Dk z?}`W}=-d{V^?3j57s$)nsC3bWuP~!0e6%$<@+FnTgpSpHZFWqw;cR8|OM2<59?H03 zX2weUIOf7j%DI!&X|ww+vjX$2U8`Tx&|&dy=WB19`C6X6bLb^CJ!(G$-TsaldftDx z;Pl`5V=j7O9@_Y?S*=B^ZnnH1&nX%U>Sy8C%X!WT6PiLa>{!v(8Ac!a3+v3L zIHI%uH*sJ(U8{A(e=v&k@|$-}%PIQYE4_`8&J=u?V&GJ_Arww{!5!azh0-A#Grr@A z2FGNK-<4j@Wb>;qLmJKMBTlYRWyLB4H083W6?YtkH-IHET#S`ggdZYa&LnH`H^-@a z%0vo&kDsrsN%icJ_U-P!%`lkhNn=a-q3Q#R@6%DxgUgmi`Z*Mw&O`0A=({SQlf6o- z^-sOU;*C``i&wBLA1zORd>;a1swKh{CoP0GA}D9Dc>JG_^k7GI#SmVRaHN6#RVjW@ z@reD`kWDi+2N~o)2$^W3Fr*pGOml?CJQ!5&RN$i)9~X;O6~Y~1k9*e*`J}Y2ETze+ z%Y+lShY_@aqwCN1=M>KCfD^e;)L2154K&c2ay4-mZV3C0lcn#B4aMvX=wjfll*4q4 z|9>bE_96MLW4{ff3*B&&|^*^vWHX-!4@k+kc7(>5GSQD}~C3Cp?`h=T}|+KABJ=#RO4 z8cx3r#^6R42z4i@zFh}IZiwQQ)U%sKjQ$e-}Oz!#EvJro_+grF4|h~`rKCW zP^clF%w9!({2j_}C)rbFzMm*OuGq%`Z?`1}vbi^+E#fkzoy=A+8`$x~{vX4b13KCI zl*?8O6;{D%+G88=<(y)?05x9J*%u_#t56t1bEQgvNJi#%H!vZ(+;=O&On-2YcVL<~pMrP|p zP4~1y=(x=Q()B+u=F|5*s#s;g8!@=@pqedu8oJzb`u8igEtWjKZkUZK!-;ah4uC~` z^*JtW;?c^1Tdpg9zZ~eMrI8ySnmn}d_Dk1V@Cbg=g`M`(cYBZ-X^vQw2h&{9sTSus5fBphpPaF#j)={ZFectc$MI5%Cm^ z1Yx*QEpK~D_(hw2KZw6tL*UL_*4D0%`}mCL57 z2xNqzwNR#C<^oOb=J93vQB)YeMje*S`BLk6bLdp>JE(?CC(e9ZPEUiQ!sE0Bug*4c z{dm~z)Cdnd^y9nAIButBioxddMY%=d)q$xI!;10NbpEcX*3h37^wPDZWTm(I6)qEPF4qwNE_z^$J3nHgRIgKpdOrOH3n1C$T3U z^caQcc%b18?OggQ7ASW;~VV!|bv9N>`N zC6uD6fYpwS7VL{TAJCM&->)$feZyQPqN-vw`#vlTrp(WoOMx~U?hCpG>{F%TO=X4U z8VatQ6~@p>6yu$qktu~bA-Z4$|6perVi+wP%V68*U{q=@Qi$Ighb|r_5%|VMM(~GT z0)$}R9kV5S!VE*wKtrrreL28cd@;=v+CS`1;KFY`zKRj{;X1QPkC?i-yw%%tZ40qy zw7umM3y=Joz15OehwSt`3|+>~Neb`r!`0nS_!@D9!przvDb?trV7|%oRoiIpO3(40 z@vwYs=yijc1j;Hv;4w;@2JVOxpYt?o3ML(E30;f^8_hbQZSZpwvZ`e@N{x&D%YKdK zD%c648G%pCD)FS2*T}*Nz6){^4)>Il2BPYd!t`|TXyK&d^p-?A_DD|+8EdeoTwf|1 zw%=!S?S_`2kM(=X^X`_3t}`{e>kf~d_Qpt1(i|;rxC^MqTUr~H6G1a1>uery6a0|S zX}yFq`A_CDKUbZ^9aps~Qtsbs8acT<;K_t1I?$u4oUz}!0`8R3Eaa)5Mv=qLrFptI z)4mtle(eHVBhTfihz*x2Q_XD%t~r-gj8*AX6Kc?7nq-Wuc2OFrO5gDG7Rhj51lromRK7z-%y!M6(O z7!i(+LsJLEJ1w3LIe#=I;`n#nIg~@c)BvjZDQv`^g5WwPq-F)+R(h$6^4Db46!X|S ziRD7ca+;U=-g}siu@#wfC`|zCS9*DleHlVNSIqEqEU!{&JT)xm;W(?w){G)IC zyPk$(!Z@jF0|w>N`90>JF9_I~==z%a7hY|PM_9^8xXD1k7HFDRye*y8srau7*; z;at~#l|y9z{_Wp1_sI)5xcT;c)%!0s{Kuzz>LthZeNfoZ=C5uFbZR3&l{W$Bj{`rg(4$RM?W$1e_QG8)yiUypi{FFa3QLWLK|h_ zT&0lY=bxi(Knh1l4vZ(N(;TK~Nj8K!e7lpFgKZvPg6=jaD42cq{ zOxonT6Wjci_cAhdKW`y{DgHD^t!CkIss>jaQf37kuT?~4seVo^=8wcz>5nqql@dik zeNv%*^m>j_Mg^e{hs2~hss1U-5{uajh;3yh4!ix^`^Get!zsGz`-~x~A_R#E2|Fcn z?dymS^`_#o&=6#56~y1r!SGK`4pCF6kb3N(s5%j&#?_0E+f&0}`+i{GNu_E!G$Wd* zqBa)HKbNtxkfBiO1mivk$+UxlJ*#$sxFO zC4-$azwDE2S7L)muY=NHsgy(-$)(hlAW-zh8}Fp+BMqm7WJe5Y8zsV#_ZaEu9YVpd zLZ>yzo!X~$e*=aO$rgRj8D#Ix{>(WFiNOY|DY#H!L^2;;O7e4YZ5XjdMpuqM6NL#Je0T5AUlD1Z9>p&|7RECHVqA~dV6m7A!(7Sn!Vxix&b{PRD(j1pMcn#KV4H zEU00u9|u1C1OEBJ&lfBp;+GmC-UB`_iT~`f^h1s1@rHrnZ;qd= zc0iS-n)CvQq1U}#h?{OU6{5!d{4>aA+{dKxh^M6j3KhT&CiMP_t5*~<1hgUD>qDE!@#sX$$wKc@vG~I1pRce%77NGe0aH>v_+1LgG)$RiKxSBqtXVWgFbkokPkd%x zrfV^Eh%b7a9n3*9)@9n;3Mp>9^ooHCGI$t9e=)D(nY9QJ)O<|TVvQAW1bWC6^2b>V zIlly@{ZSD@ogpf$Ax;!0{WE&IuX+5(mjNXa=Kx9LCfSb_RNv`yGZ64pvx0-u=X5>N zm)L~A57n!YSx$QLX8%mHJ_}-FBH__si9tERMj2)%YtIV16pL_*MFKj+lrY1lgV)rw zO?AbOJ-J~J_XPO+u8xX|YWFu!`)65nbU8%(H8RvFfkW(^$kWeilcj@-78cn_&Xr@4 zGaRasV`!x6Td75SHPdWh(pCqDt}rRA%{vT=EG`vfxaEx5GL$lsqm^P!A{+&mIv}(* z?@2LkfLiel;hGkl$%Jq3!QnOyP0qMa5!{Uf!88s;)vIW?hEey<`HK7}B!{JXp7MxC zAo{2aD+xaK;_15r-6H>0(42`cQ_M1qzzF<*?(mP+7scM1>T{h0SaT#@@R^6MdU$`u zv!gC*Qzag3AFg|dZ?^`FK+(RRzZ9oxfYY48roMO;P9K^gG8?{`xlAx6VpEfiNV70Y zfRhhxZsmu<Z;#GaYSs1Er+NQ@x!2<{bBgBC1>K61vfT6uZazYpBe z_YtrSO>VE=okp0|h^!CO*-ehq`J1=2DcYB6rQn%|)|nvV zn4gwi#6@akT!Be1XLe`4l=Scdn_4VmBwkXL|XDUi1uk z^m_CZbxElCQ}}#H+CeT|l7*ezBY4a{F!_5{dkDmEYxwdevMU_bm-)Plz8UxF`s!?Q z$0_ZXZcWLEdig0gZzQB9b!M`K*{8P^@#2fETk`ZaXZFNt4)BJP<(^M)#S&8WBe5y~jh$Ie!VtY#_NMf|{q*;4ef3@tk&sR1iNFar>^N}^4EYz&=zTE5#*+k~# z;e(BO+f+jrQ3m!C7UtuPJ}1%|jlyEdzg9hz48zM#GuIO>=iaz?VCkvz26z z&3<@%2m|e7?M?w1FWg72pVEY3xOrmld+<+#={&>aHw2=4&+@wve^EkiK5xGe+%WPj zq@VnhG}+|%S*jwI^Q6eLvXdvt9&pC!Z^bLiuv^;drd=3zX2NZn$gI8f9ws|6SuI5v z`wt0-K(yMAW$KypxAawo!VD305*8-%To@X+58vH6l-#*1EXr>ETooRo!i zG>?fSF7?!bqOdZpA`4OsEwkz#fJMOC zRy^D9e37s3d8}x`8!m7=f$HmUt0Zn{J9q@ZjmAFK5f_*2we19#M1yDHdDevpKZ5Wfr6hJ6_y{5 z$*96-(%y~0T(xJii^9S|!Hb~5h+vfS-aUl75$hfKw-Y4x>tK}xZ!dL_EC3ug00}N5 zgI*+ywbamfupTwKuz*AVoB)lEjQopwkNm*smrw@$j zTLrW-@M`P_*LF_8FX#I`WM8|f-QjbCNb>nAD11LQ=uGN%`(*@`2-HtD>bJ>bQ@*lK zqjcnw6H5nLYp*m+UGMr0t`5RqBqA-?hp!nx2yq4O`bVQxyUo%>K>kaFX%vA*$gqVGaI8p8Xwv%tU|yWS#_F-v5Qk9SJ;^*$X{l z$A3iVHX;0zE8^Xzz}i>82rk-$FD^5@+DxlwCNu9#A2YTxoXi0J3kv;bdjZuTRE+!M zywJ)beh)|~hMLn74nATYVlbw9Ges@GI2(5ugznH~cvsQ3;#~7{ zO*C&55W1ca{ca>yt56ICVhvN#1S(#Z=y*mV9fFv^JNrc4 z_&u2QPABR0xa=uNLoq2khAC52v|E*d;C~Tfd^WBa zTIzh!JZWuS6=W#$7Z$)-a8ftL^Z-s>5uUte`z%C%aP4tsOV?K|A8c;d!_rT<98hOI z*FcWgzt@_MNWh3p{z3a_f(!9&FvKQQ?%+?J_^xQDaHN`|6x>HI`)1^{3f$m|tLIo* zl`Ac79@JY(&=lseK#;g}5on0AQysOHPY`^b8XuOVaPaEy64EU1kkJ zGlASizfWvd3Yb~!+rGDtpL&q9?0Nv~)cQ?(t3fCfc6y{NW3zd@Ls8?8sfH*h_iR(^ zc_tg^@-iZrJm=39H@wb{d8>Zp%-j%{olR*ifBn#gHvd2 z%c;KWGqIhk<`(i)(j3!GujJONZC@Byv8k2_w(;XY}yv8@eIBI3o zJG=cceK#9ribiILlezXmgdd@x*xp98B1}IxX>f$T9&`{Z2_t%jCebOiDo0XAFyT@t z|HZw7f+>>3iba(+#!p^)q}pJioq(U8>VCwNMTnLSJ(4EhDhd{LBq7q^_OzyWNpua> zu?oYX#P?QolySY6(?LdbO84zjXUaMc<6Y+roN8=*xN?iSn%+*)_^dg!8k#H9DxO?n zX5my%eq~R3B&O4G-M1+Y*ajsIr%LPy4ERuQa%O6@mZarf!5Hb1v)^Ph;d=`7=m@0Z zwl}}hIU|d56n*XL-u0Z)&EQovd@|uhR{Jj%26wH`=q{4+FVlWVgE3Q5!O0XKWT(8c%ND7Q~bCsU4qP~LTAtVsvaIXbUnJqL{r zdvR^5+-2TARyv?anGLO)i`CxpCBapfL#evX+K z*Urfiid~pPYp3&cbo=0}TAs9?E)F;6#kV0-6UubTs{}C@yK!J`Hr#8~-i5R~I=_X% zxnH|1#@3*sPOC7#=LT!)x*mjL4GghoI9R4#TiG{qSW8(Y7u*;wM1n}BwhrUvdT2K@ z&$mmVY4-k?H5bA{8HJqU#~-#7UZHH5BHJ0(+YlxLI`mS0T#Y90t^-Nt7iZ1mD5#sS zxHPHjcg}!=%TNlBNtV|P@>KjlyW=Q33S{=-e_QNpx)48oYSM*i#F^{5Q9#z>&7r8T zJRyr);2&78^O3K|&k=$589TGd^2NcS^=`#b(AoN+n8lTRG@?n}Z#M#$)f};L!Wv`P zbOJcS2wiE=?2Op03Xpf&pGqU&(fh-9rqt9Xf zK{%kOx>}tY6CurHCoPQ`iD@#aV+HN(qz${4(>4SJNB$!_JC}Upp8JVFKU%JCX7$?U zts5e-7^CN?!eX*}(L<6a;+!8Gin?rr?F>5`h&4LfK8-!fpv^@Ib%Ti=-}6NZafx|y zR7=^5a~u6RXZrGb`BYhTOtv%->wksi!Pj4+WK9sSqa-;-ADfSvCH6`gYg>Uj@k_yVSYUM`kVPGrI#R67t8PXc{>}d@!V4Te*%Qi#^kE zJ^tan-GZNs)EO)&)dgF$VqMmIIpn9rsxVT>B$-R1g9Lb_IL>T)!AFT zABxB|T}izFK>9-kf2Z?;!;?Z%j5U~uC>D8=%Hh0d;k4%)u*j^y0>2s%G2zG)HfP6J zq(*8LxF>zE7~p}<=6IY8%pNHZD_Yc2I!3(PMtB6v_E7hOAg#twI{9qzh7FO%-N966 zV8bc&N-W{ykyk$ei8k_>4JJQoI!XOy_y}6QP3Un8A_h8WmRu0eO%SFAp6JN3IZ*46PbUTr^uRVumrQ` z1fG9|T5&O#Jn$sg!Nhp-MDKLDtuvL+QmC|JU^+8;S~sZKX>vz(XF|o}{+L_S#Su<}O$4;;9Z$pNp_`F`Z~IsKl^xxKM@t$S_gfM;GV*%%yiZ z*_*&f`s1m@okozB?OHbXOHI9*y+}a!)#Kk z^W;zoW|u9C=y=kc8wlRl3}JV6@^U-zXJpu2=gd=!Am6v*T%3>-W^Z3Q9sKUB4N2<( zi3YjN$k;Yfyq(PRt!EOOP5|+zencJr_}(KGoPG547%YNw>onS*|Aon6JG_%P=Q*D> zpkau9KqA8`I}x}Q*`ZTRu#)3kD_ql1B{_9?OxQFZT@s`{TGHTpk@Kvqe%7h z&%NwzZ)#Rcq9l2LjKBF0SM@i4j6D3WwWv@27`f$PHu;Y~{e1O!XZRm~`udw4Fv1@< z+4xBzG5F2MfA;(*(-i>{qGfZB zglAVn9v`ip(s{Rw<%2ejF?T6U zOvhIBMMn~Ejg!4{%+A~P%?hu6)9tFx_v6L7X9XAd@zS=jvby7jeAQ3glXv`a&y8F1 zHuTE4oFc|0C_+O#{!%~8!Kk+h{^`m_qTo0 z+DjH(uP$oW=F#oRmc6)DFs}rsrvbfrkqZbfZt=Dv#w+Q$hr2oLyX#T39)>qFA#NBJ$5SkNtV%bp^ZCE%Yo# zQ(g$giGlRso}^B8r*)hG{5mVu#S3(?5k>}sH9+@-2B3?T_i`dsv0VIlz|{=!0f`A; z+r~a7px@F{05WNbA3vzcxW+}%8r*v`E_cDl^T9Q#qYPAgV9^=4u7+nD6s6%dxW`Pn z19r9dFY5_8ryZ@;^~;X7h)HoZre0oNG$2ocMH?lhR%mHs5v?+~peE!?i=KVs{AU?C zGT9;LOxLf?r(hq$r=sAxxNE)UD0zMeVrfBmb48l*^C{6YJ62Q`tR?9fypy07XI(+cm&At8?Qet z`Ln_SBW~Udc}CP+23aAzG;Q&Rn$3z7Lu0^GSAjMEI+?*=h6|@U;he8@2UH!D@3mOp z&B!jxX3nmp<<>fs*#LR5kGCY73`LJsYZKeXZ@_!D>MQgeb5DK~sPF@} z2mCtx%<(Z>PsP?lWI=w<=``bh54C#XXh#lNbWsoL>U=`VgcweYzkbTCfbHE(o?Sh6 zhR);peZ<79ksmKKo@#=$N<$#)D1}f>ibfo6>|O+yEuXNeyCssi+q#N1t!7-3GpNQE z<;@alw)i4rKCA}EC2*fe0Z^_eP8W^&9-_u(izF?^eVk?= z)Q2jK2$n40y#{~Op%FzW$C-841`pC3O_U9Uz*$M;1FL##X%B1JKKJ$kr42h9<&FEv zsX;EQIw9-4>}-qZycx`yktpia2OOiwbhN!;1qr-R1QikL-A2`!Y!myMec9Bf$Igs3 zc8Cc>q%@2MEYt*`xE+`YnDTg`?&XZQQ=-dW?HRRNL& zhQMsET9!ekU&<*bix=*Rl)H%cz_o5<(GR#6iLrMat=sa5q~z8%NplMe&U}h8NXq0) z#!V@i8S_=_y3e9FM28Dg>qU2#*AJi^(wv2K&vbg#&~3M~*4Op6UxH>M*3C8lwjtNb zOGZ5^6H^Kutsd-2TJ-yjDlwa^{q`soBX!HmT-0N5sn@vd`e`{N%G@wbe#oS!tbY9h?I~=>Mi)gWKAFnAtnfh(j5OPP)aw|%CF1e%y|D7)_ zX)K`|zA>0l+B-qv-Oz;8c$BNHyYV5|o{D!GAp4L-`od&cSxG`DHdZwf)Rz0uWy0$B zy|={I@fYU&!VB4K9GY_rUBuuGZoBGcvChrY7C+y55`wXyQ?iY6>B}<~+uYyZG#SPwokVD${3cIms^Xy=v3ZWaNDN0CL z`@0HCKA%SkmVUAaW3##pV6ZjtQOYbC`b=0EHHC+Vw?;@`)&YR5BFvJz7sQcH1T+oD zUU_F`+3Of>VfOdUL4Zl1O)6zH55cpk;kd1I`qYYI6T@E7C>gX1H;)d~s{2vx{R{}T zW-5UhO}oWWXI$fu`WOqq^{=rSaI5oAXWfq@jml4Mv%CMx%$IxD%tIYZ>Nh{Tmd*>`)}OI7h_KX#P`0FioE*?}N;Md7 zvSq%iw)yM$v`*fVzh0iV*0;84)J<8wbc_xpcke=o8?Z4?NzA%ogV8>Eohc(S~tP8>l_6NuDlX z*LU>U8{bhqXJ&22nPs)Q;TS`xJ#S{4U;A_CHSTBD?JYB(@R;6KA2Bi^X;HREyD>AZ zF_fkniI7fftrJm&H&PjHvNj$KXn$(c?d}+nF0nor9_)Wk(hH>(=?SP2CYdm0+R4P2 zzDi*+ss+0Tq94W8D{m;yO~Yzi<%IE62XO6eb2v0fRYc?QA>r!?`{`o?BZ3QITzn{1Ao8+;Zdp&K;`(`r87Prm#;f;2!1fi%?-Xj_(UvA$;yLWU-9_6ha+MI)r5 z{jo(zW2DHTM9+uXkt&Db+v#PZ-CVjvY+pVQKXy``Bq#FLgQxQh3!7n!>PGIn_a2%_bX3f%LKW%;EM>hGY)r>P_ zoqHNCpiwNO%ev`w9{m$d=OgzZ2G)3N=80noU2Lu%(ESAXlXFmYl4`?cyAt$v^cYb_ z-)H@Tu{fYRf@hbaEUPncE8Kbb>Mhv;IkQL>2kOGkAY>{X?cMd3C0On;x`jS~JfF2QGk zUuMrr`BJ-jk%TJ;%w!MsEHdRoGh1|hIA)kNbQaoVMgj^nQ=-Z6u3?z+rP1y7S&NvH z3hH=xKZ-mMoqC*)YNu)WzvnEwy&0eBU00v3CkQ4LV*<@ZGWG0z-1<^9zuy2R=mvZR z56esFKDuvHST&l}a4Wm|X_EIwB)K_>nDv__5M9SJrLCn=|M(W$ zKa7KPA11%%y3Z1(o?A%H*D1vRvrt|E0}9tpp=}8vM-r7WY{jT|`(P1bX=OhF&rd!u zhmb3hW_w8sqh?K`8=?+RN<4zIl)9^t8M{Leiwk0lmsHl2SpikyrLNwMS^Il5TD{4Gw4P4gE-gz4T1)toY@|Bjl2ZEBdNmcV=6+iyo#$LWmS8}9i3VG(CT$I z0?f3ae3nkMkc1yq@rcf$^>NbaxVxyZrOvd!*0ic;%FL(wNALp`%KS5J~ z5A3f^*7!!K@`&R8%>97OTwB$`-ODM_aZ6adR8-T(WbWd>pAu+e5RnC!DzgZ!ik4Pd zen{Nn0>;ovx89iL1+lH}I#R0h&{cD2hd?6^?qEO67m|ECzN_!&yv|q?0Qc96-_XtL#Y^gKb!WoxH%1 z_CW7ToVG}}Dqbx`=kW&@{nkEDWs?)X_pL{c&t)Qx9c+CL0=;K8K|6hKorNTBD8Flm zavn-Urf`!+ywgc{=eFH-_Ds!u(gL(o-~4MXGfx@5m0bd=sAnyg2g~*jahIGgx5(|n0)ENV z*jQPrp!hJyy3DC&eOW*joaPB&E2v``07^6-cFRk)JB@iesQsK5jlwKmpwmLYoU!n< z-?ZGaLs@6SItsW?6AkI|T{&b9vNQZL(S~u=g79rO0~&I73-D{p_$GjDS`YD1M%3>$CJq++i3ZwNPqOUWq>iSB>D6-6GwXC}xKYircY% zE1s{ZI=?G_j^i|@ZaIESpR5lP$6w_%p(Cekk zYgGbd4eJ;Sgu0A#$nNMk&uK-7c1sAp%$KQ`1<9f-@muwE8k0`G1*?B}?e_-JzkdJA z6ODKBWCIoCE)XJgZA4<WcY^Tq!ZrQ&XKTkBdq{xWwSaT@0Gd`)1u`Tn3 zfaO&M-YC*MmO~7bDOX%K^Epm36I--z%23vgcb!i8$<0zho!7H~-Re^FAW&VZ2i#7* zg(IrU;YEecM?jb)))uxO&hBLN^kzNhjcgz7;SH{>i=+{f*b112(dB!1ZM>LN309wi zA++qz>`ETXD^|)e0Zmg@MDPcPR*}{cfNYW6AR5$vmD1Rh9COI`pS6#jgX0-q+^tNYpn1u_bwA%WR=+w>=Dza8w8$nDMmr&Z=RSm7_OBtJHf~ zBDf--{-T095V9Ioo%HmhW{trpsCPr}Agp*s8DxrI3bTW5k5OhE*>T}PuYLzeE z7F<1UP>#lHi!>QPXVkK7tgABWqq!LH9Ow~kNsn{;DY?@6TAmo`u!KH=4={Z-s;!al zN-@ad*R{&B>AYN-*wLyfPgG{5x4sHOy&#gxA)i`1)T41<9lZxbEcU;+sMEJ+9bg&9 zpIXR>ihQ^vZ1>8V0f0YCl#a#^Sjt=$mQNNlLv{(h%8%M}Bvni<>Gmp+4*~_+gy^P| zqp}@|bhTna2P=t~4fcdj00mle+5kgn>P&P;s^;$SR4)2}eP{t+{-gD>xS~J`2Kp*> zD=o&&034h!Ob!#%@Gf!~Tw@n%en8)VKWdU_C6j01D}y1=(srzZ=qHhW&Iok%f(1|h zIlF&A?K93QT5bt!e53AP4Mnt$fdR6LQ_Zst#|W>>V%->-)s~TtFy|dPo!;tXDpo{C z!Cw{-2z>S3_}?^4IM6u)u2W|{J8|F9`nDE0C>bp#RHMpt(V%7ZN<6ub^^;W_{*|UT zi}y@AMG&P~X8(9@cEH25%#wJGoc4}wT?GKoK?t{TIznzdcl7q^B3d9o-ih7%ouIGl zca}wXsXO;^Vm|E6>eaE2)jc30<11n?+wC2q>OV>z1I`@8zS_?}%@(JbNJKv)CrgcSik zsTDd-)`)%c0grkxC6ik$+HVbiq*AexWi0E&V23f!s%W8M0dOWPZIO z30N#R$T?mTi|!qWE{X2h4fF#(iJO(Nr6n+<0buB?+=o7JYCaR|LDj5a5V$L%l8653 zbZ;nmPZJtI#p;F*mW7zB8C)O4tM!iIHf#tz)+`-+jkX@_S|?_QpzV!!Mq^%WFZnve zvp#tV;3VTiC{^mgDbaeYG##+o@H)|0C?E90Wlk_JzyMS+EFSQb1$NxM3slKNXoFl! zCtiTJE0Pw|grzs$AR?38)N!9e8#fi>dSeBH+DAVx>&?W%~+dKd8@ChlWSOQHN`^U8*&&1W&9sT=hdiueI$$*rr#VtxD=+l(B{!@w|T+Rus$!w!UR^D7NqV!&pMgCozLb@FUGFdR2#I zmyk*33b{BA5YiY|w(NBk_`;t;Iw0IdCDaH%w$7r!9e$qBrt#ePFt-K#mJL;8RVh#m zD6PdRGiB1?nD>L*V*QV_-q^L!xo!?}eg0nIKCC<7Oh+uRX?H^m(DuyYJJfKJ{EiUs zof{Aqwcld#J_rKV5ROSb%_dKcDq18x{N1-WPXC=-0EK|gcr8bL(W;1*L=7J}*BK|8 z%H!fF0G5py?RC#wniZEAHMgnDwm#T3)w<+$(7y|m=Lth=+@uV~i?EeWLn){xwWV1t z8F?g8+BuAvtg*6zw)(~ta_wrUVp+U)x48X1R*W@EHn_s68q&XZp6h9SPbcuno2$4- zikJAzCszwsA77{C{sUlA%Um{tFFxu&WE}ZUN)ZJnTpk7nnKZ^2RN|K}t*e z!X-2Nog>@-hd1DVe5?L4GVbd|dB6Czs}q?^MbkxAS%;DdUP&o##fdLdG)vE~dkd#M zcTtB^wp6uY-D21Y2=>m9`!M7Yq|r*yS3vlY55F>}Q~77UeM}#E)>P!3x={i_dztBz z*%Q7f-fs6y6>`-c*#v_X9JjP6 zIcBMikcC)Wi5!{(RDpK1dodqSMi{kc4<_(k=92hhfXF?QU$)hfJ<>^Er%LP{u>M(l zyqQb&dYi~{*~^h%mGm0im(z?5g6|W=vZEbj@i*m(ZeJkdkav{2I`Klh^#QxLqy@B= z)eNrd2RMY7>jNH5Ri`Dr)bN!~rOnBaV=9 z4&ZusT$N72K}ihRuno>PA6O2&%=--s3qrdTqPZw;jJ2}4>dd6`q!As_z*__eca*C6lK48CL%m!0S` z(+~7MrAVr=L&{%=-8#d+%6yZO-32Jnf}#6BF*c{ElC`?px*!rR;bB8G2#q?fH7&7e zSS?xh@>=|N=Ei|f+cw9LPbUgpm~%x@*3ZkfF0~Exvqzz(IBJl0auh-ZE7_J!{%P4; zQnyq~GrExy%b}JAac=F$YEsH95L(@A%ni9jm7+%H)imnc@!ST{G#K10dUd6i>M=sv zsTjrWN5*puEr}=?LPT7Cb>U#w@emM742#bp+WZ{xmX^)2JV+H{GQ2hw*1iol7AauBLy3xNrZx-MRg6r5aMv{9H|=Z-cAz36<85 zjG|9-n)*1%t@tiLUhOkj_mbt-a~TYoXYX+6Rl$RL5DwWiMfP6}b*x?~cLbFmGKw%; zeSUi4^6r9{k_5T4@tDeN*BFp|bmc5ei3B*W!8HFSET{ij?7gs-SSZ_4@7`(oY+BxyMixG{v&Hqn>|C{ zT@armj}+v6a)63gJx0^xIDl1dTq|-`s3S3(z}?@bE$0f z@Vi=A@uP)IwyJ^k1VtuaxNlg|t%`7?9vr6_#nC{Ii}fc<*U`FRh?6spybw9Dl5ktk zR^fkeKtrzQ!k8g|`2-%1K37HpjR>h$Q;_#TEwb=ek9An3UF$Fx&!$452z zl8=hG6iw(A{qoXQ@G9$N);EQ$)u}o5efsTm;0y!hhGr*gGA0GFV8I8!lsxt3A0@Ay zRpZBaz?;P_DM}`92yMUu4GS?6F3~acDh}{;yzlH$&PQ;lrpCmwZ8Z*|jfJoI(BtbI zd*u~VwO*`WJ}DsCT@o2Y_vaXj0etK8b21_+;!(8^3n;n9+;rI4 z&Mw-*IL6?0Kbfqgl6VPQ3;0nra^PJx#dE6xu%&lcZG>AWNzAL24XU`^>*zd7Hwc`T zABJil%n|D}(3sZ@_l}(W31mR(x%Ju}UYFw7&sN0^E~FDYBJ#u5HjPgro@|wVN|?lO zRXd&f-abJh?T+(Kk<1EW7m)bW{$nlykCpVc9OpLHVnvi4^)R=p(rZ>BTVZl?1h=QL zHqO!j4>Yx;g^h22IUmOWGWz)`0673Ghr^v^_qZ#Fkh-XJSY%rj&{#UbK4R}&c?KWi zE@E?1hn|fcb=WI%KwTEwDBV%6S{GZabTf~G8k&N=;KMaqoxECkBwdu-*58G?OYN^? zTXef)R;iBxDIB7sC4RDjL(8;B0h9pHLV*SPg}jrqcC+G^*<=e4)BS(8!4BJ_XHNa( z*vSazULZ!qY{z_=K$`eA6jx(1iq@lAnSE5~c(O7FIGFI#E|u|xSvNL7@=dhAEophC zuUemOov~7=3GxQ_!F*G4P0=wvr4~=oX=%efa|YxHPExN2q(}8%=l{aZl%v zS8kot*KgmcxWYg9XJ!um|D%iFsaq|`@#oHgrbztSxt1kM|JoSa23b<|XO0~>$%5_q z|2uTTT@4`3)QvF^OKHj|CCNK3UO`=Hgo(Z9luULR~=rgjvTs+7QX|5T0 zbtN8{?rk(bm+XC=OQtWsn#KJ>=tg!$DV^UJULcK^U1*#=*f~cJ%<1T>PF`k$8h~A7 zTyk8DM4J0M?OlYS@iT8rsrBuQGwv)5LAypCV2gXgf9-q&g6AIbm-Q$m!3Jomw_dhp z2q+-fF_$w4fFrw+H%q#Q%77OYEe)m!4(8Q$U!MgMa}4}bqWdq_6p^a3bR59Js3vsL zvqC%1B9ixvI!+8Lx#5^iewT?ga0OjtdB14kxI)2uCPbtlQ<|o=YVt?TQ$xZC zPt?#L-6I(KOTyX(=j(&t4(hWj0FK|-{2CofEy`p*k}fCr2}rh3FZ=Lcn`;h$o^(v>L3=Eq2Kia#mh zZUO}OA$xrb~vfO6!Lr)!B$U15gjg0_ok#aVQyw3IylmkuoN`fAQ3%!Rd zLLZQZ2br2iZ+zZ(u2rBf=wv@?Dt^phNK2aS>{0dfA9Ki7_(cmA^jP|L=PE*Tv!9Xl z(x2&S*997zL}MS?1pv?;YEwsKT?1+`J3MEZ#93osi@I?7{z;hp318xvI{fafZX3mS z-7b4zk}u4_J0V8OqX|SG^*#qC-$K%BElM+2y8*mp#Zv&DCuISzJ=!TskC~k8PVIPd z3<&5h-C2_!MpV*?gi~mL;{hxC2KX#&S| ziIBA(QV2>ncAF2=%TplfX>#K^kNb&V$m>(Ah-H-$J#iHTap13!D^B8wF9Tv*W<;Dz z#c2tblGrFPCM|l>m`#2fp`Up;Oa^kh-rUraC*9SJe+@%GY31a|P+``_Vf(`?kn^IN~Uzm@A(|l$bd44@ArHp$noT(YJNp>=r%le+Ej$!zt=@wx-|}p z4O0&?XaMFP)z()##tMD?jF|Nho>5fMT~7z^ zNMRUMYUmy#ISDF$@*q5|HxCx{U0P1g8F{_(x`ve-EpPaK(ZF*7d$QRXbQs*E z0dJL(X>OR4u-Ww^&rwyn$;&w38SqY^6X4HSBcuf=-SjTZTSOFm?gGA^hX}S;v~0x( zfcAKgw&I-5Qqex!W$uP!Sym3MUOy~%B9N)NPlvAf`eQi;)tCGFD!Rp8^37`AR)S0N zP`y~Jd0hOE9TH&1{+=8CT)5{9z1CoKG%J7Va=A;rEfYKQk(4sx6!ZO&*-oH;>j8kJ zN3ay<2X|~#FCuH{FWJCBx=JATNNd0}vODp+u z>oNg#!De7YywJ{zthuX@(D@~1zL4~T=G?>;1!M1ed`A~v@GmNIqyPiqYbPI>i@Z+e z!itTCNXW`tYo-&uf4fNSpPVrD;65z=l;Uu>&3r3`x!|+nH*pFeh1V1W$^z`YW((uH z)csjcbtyK6v$ZdsYX>GK0uQ?nii{- z+V~t#1_7tijjaFH-j~NUaqfH5dXJpeh1ym?)>hlPpdhk|0b)y)TDPJCDwrq{qOt^x zgg_v%rGk|uYFR|KDk7CF0z%l5ND-n5Bq%}%kVJ?fLIRN`8A7&qf_l`J_r7;I=bn4t z+xaV>nanfK@|)lKeZJ$1FOUNRng-Qjh%R6j(va|F?a1W$XHl)_bpDi@js&afSoO_< z|Krcx3#)f^#~2BGpuPHzqsEQ^CID5;S~)l86-L1)Pns0@Ewut*cV z+`x2^KKt>E3^8&d z=+IO5#c8Ms4-c4H)kEVA!9wInyDkWWN(iA9ej3;|9QrAW_TMT3u-QpwJny1j}YA(m3d zsrihlD67U<>M8nXCQwWQ{lU1O1$oxKojOjd9TyrMfm2Z<@dpfhC+!O)?!@PZnZ~Y) zrTqOe_od(*;4E}j>BJoxik$B$7ymka*oIUdo4amQ_9=l)={F{oPFk7h4%^T-ny9qt zeC7|@JR2*r!lPQ43q?)G2ea+j=q#C^E(Uvjs&TcB)=kIPqGNyp2$444^ zX)W|629M^dv~yutUM0EXLL^yT*^&tRFmgLBXXS!J@$D7Z96vQ7v50DBcx2z<71hvW@y5e@}h&9Cd^BVfEG=R_PXqntf z*jT!vNiYr-BhM=MI~CLmbW17$6BkQ@D&lReY4?BO5GW!^AlRlof#7EVDq3Hr~Pw zB{yAcOY#up((MVdOTsJGEwlDNt+8%=78x+Zb}+c12k5>LBe0%D?{6`17WaNcVOuQl zvH$U@@PIDTEF2yyt@pZ_c8&8;Z8k|SNjWa!IJiANE+fofEK_Cf&ZEb(qGf{3i5Ufj z)24EOPtaVF`G{tWeGG4ZrK>@b+4(jq8c&!(5sZge;LBh2vp8+ zwy3KQ#^(=n`gGaler`R_h0g9Mr7MH&E{K%gCwy@#E2v6%84ju?4HLH-oD2$}^& zoc(SXMIDcJY0C^hg9fxP7WnNQb2={j9G6Pb`3D40P?&;QVfqGXjqle~qba=XbPN5q zDxe5)kcw&na1jHE)}-h|GJ7v*+qpL2;!kceQ&@<|beIAB#G_rL^e{9fi69qKb&e%S zP?=-{!o36o^vdrPirTU@;iGA*GaeQ> zhb|^=;tk$#OAIPxK3QKoj=+#)1GQ%alL(P6V8wT*5_Uok`Y-_W`^O7 z(JPK;;wCwcaq+r?2ViJLZma!+q-)KZQTiQiLm|fmoQVb+IkOr+`|e6gtp;j6KVddhLp$Z$hL}udkzK{xHSNA(7K|b9(Qkrha)!LR)G5l$H+^#LL#2f(?VkNf{=E*c$ z#WhRPaGEQAv~kO>afF{hX1IkEz42QSoirg2O7tq*rBQ9#%xcBWVwI*o1lT5?iepEz zB&JOQE~v+x!?fKpQ)Cou>Zi#_W`%@8+y*9#``A%WO%L$>#ZJfJS=MMiA|N(Qqgp~q zs~Q9z*357YQ}NFPEoq~;weduAU7pSOel0%wae)hs@U+ea8ePl)un{pL(@a}yH(x+p zN>KIDewNcqwJb@A1--0KwczZ>zmugZZ8#(8;^4CPe#ua?7Cc#hOkWLqsZv424--WE zZANz<3cY)%S>|TIvU>Y1bgUQbmJ`o8#TtYSM_U;n^?VL5ZG8&mEilU~2jIWu$$uTY z0EW9DCFI|>@XhVTd5NummGc4r@?tCI|0WQ~u9r}m$BIlQn(gl5Q?yQH)utsI@7Q-( zFJ(K(xw^>rI`8SRM}4eb$QmC|cwRD0{0)j7hLl>Szp=6D6$VO=vRV4A9?t!8tNp8d zk-ivUSoE6WV#_Ni(Jx~ul!Set=)Pin*r%h5Di?^)p)pqP6<;8UPtWbsygM;edVUTh zGKacG{w&=b4vlM=xlk14CWS^jTWmQL=ZYzAxt5oVQ^)9zoP0-LlK*`-GpC`p)sYx+ z^KHWSEt!?%?8@lNnOLbiKB8F%-Rh{%5^tUTeSfs{Am?v_7Kpw|T8ltAAY`9y80*c6 zw*%}>7whqXf4Je1h8{Fhcq(UmpL8)D&9>QMx^&5sgWP95joPN$y5|81lzpcqJKO== zJ{nd^;5*oyY0a-RMYF1ekAH^Sr;Qm6 zhz@5T_ z=rQkqSf0z^=$w1yNo6-DZaKCo7E-k*x-B0s1V&O5(W#U?NeG*(#&S8W&wN(!V%ZL+ z_p0nScr0Ejs8xBC0OiCG9+M#}ob8*GFjpP5e=LW!Gznp#nI;m}&`iow&-C= zAogIX+xDjuRbmM>QA(xI$JA&shMRqdSGnGtb&A;UXZ=MZLl(yWv|?i&Z^+sK6N;`B zW?5zpRf0Z3_)JX@+oyi7ax;4>&J*Z}Dp)l;8^Vw|DDrJ&NS2InWTHB~;B%08?ZC0; zn`?v)XZoeHqe34T@K>ln1UReXb7bOUGf}%%%`3yhpbQ^d2N71oP)AEr1JBhPmZfb2 zsX>ITu>D$#B2HKRUXPR@preABU+p};#F0ki*Ey>_R+1dL2;~zJW&-ayA`|YVb`n*I z&s!!;+Lu^QT^OX>(l`MlSJ*&Ywlu!mP6DP&VxGolT9>K@CV&7uR0#z`X1iIuA(Zxz zzFt7{Q^s5%u=xHWZDm2xqaaWECS8mjl>I~;_zVn_yp-6**3~dM;2fq`ykPb<`nwV^ zIC`8m7a{py*43>y!hbGmK0OoK5;!AC^{u4er?)tm&KoT183?D zW!159v5ENn;W$)KD|_DrP+%iSR$8sLK2crpa7bKSxA`L-^*G60l`YW-hK97l=>r5R zQ&4(N?RJ3gxso*<-SxB4xE{r$(5}8<0|L@7fIKB{sIUeQaKxPXwcFe5-KpT8dC*bQ!7u3zDKAMopDp#spqE-q)#sDYyzl#P?J|z5aS>-DcDG!H z0^?s@bYC9EK=G>t0 zNvh(n$smJuKwqodn<&k;q^T>)?9APTJ|};X+%gKzpxang6I6r8PM_Fnu`i_{DTGu# zZMLp3V26kj4bsafpY%6U(Yo2QFe+X(DD+$v8vVq4AUNU(fcv1aVx(qi$`(cN)dJF_ zm5N@kvd}Ph5`El*4O_IXX%E!Uy@$}6+{_AzC6kZn05&VfR%JO^_76?06VPTOH+RO+ zis)qo(y%J#S6!12&8zD{FJuRpK0GVBINPhbZ`ZVP063v;@ZR}`fnqAHvqsFVpxIXP z@8bd!7owo6k%1r;$*qWKcdpkKWJ|DYRleZ@q()d^XsrpL#w#n6>5$ojy;(6J)+naC-v_=^S+kwGTG{xP{T zQ%m{L(pjli^kt3qYNDOF)p-dp@>76GywN#^y*0kPJCapU(8lPYnvLAt3~E*KuS z7DR9S3dvp()1l!k!|lGrWYB|IUq-zHvVca3Y8;7_1i*U@w5L<}vC|T!fx&*;2bJV> z2Qaj}{5bPv(gy^+n6>fiy;w8P4lg(N6|V@Fc%HOvJR*x6V*9&SleM`)i!5+EL7P~N z7+!&s$dLICI1qJGtun*BbJp1YZO2JpecRm%);zO!dm`h`l#lXh>MKh0v>~$>aYnWu zZ&ax|u<&u{2jw(ko42F0w9Qs>KdEQI)Z(&Bs?3Fbv-TY{kFH8`UA$Vg$l#(CLiWo6 z(7SuNw|=gQ2ujQ>U5%kP-@kp>9H$b77Hh{Q`_rvSE}?CSeGP$sb-$WgNl|4LkVfnm zl2(y6)fxk?hw(>RJjXgCpBYt593w-1!-V_ARE$fm-~v+JU_rl>Q?ufY@Xy-W(vI%5 z-Np4ZutwTtBd?6keM521RZ9CN8rOQ1*|NUcJ{)=I0>{W;EAed`@d8rNe;lm8<1rCJ z!TN;;M=O6XXke_<#{c3YQ(QH156fd^{p7?05T%+kPfPQ;v%7gOcL9P$nW`DH%{Ftp zEFiy|t5=wu-y1Q9R|x&zd&wIf5CW_~c(A@&#L&v>#ZgSEZ3QcLiyNe_q6?%OSw4vE zh0mIb)fmIHA3Y(}{Yoryv%kKo4Mr?)Efo1UNMAc-^~|nru=o2(@aq}Z79>o#Sf5cL zB92R)CVx9b2N4Os_pKW~H^+q_Clg=0%gZw)fk?&D?T9B*JplR-PTjpWe%!qcsT2_r z3!=f^U7#5lHf8JX7ucUuIOgZhb+*V`3~JI_NEUDcjFDsGGSnTt zM=m}GB22{aZo`3bfk^K%8yJj$^mY3nb8%SsgE@whYYU2qCo2}$a({u;g{!_#V1fNK zFt`lDJpUFLq*1zQTs?BXQy6bIbSF6XRa7eRDOB4F;}~-T)=*Xv1rg1f=xbx#ZN_KZBou zJUjAQkih;L+dBWYXC2te&E(giY8&ieUGHBZxBhL7gKws6B4IEWjcmCFeg`j#D~Aw& z+~1mY@rQZZ*Z|@`ikGqz^++rDmuB<5ofA)$was`fC#)b5 zuwMMknTh2$R?Yv{O!~mJYHW2iUPFO>IA0$ph?EyyiWLh5{JS|$=1VQvEUA;?^3C~XxwUsu*gOve?algtc_uW4ayw$4DREHz_FL=r zUS;eo;GE>wG_+hZj&sB}D*r2`>mMq*|05dzQ~A!XS&t3oG%ah-ZdQ0dadxqRRrtM~eCI8oLvw0KNB&{GdW zE_V{6Hd@%&CPiNOqpH_8&bAGkx@P+TKZ>_3dgDTIOPE+PM)}gk znqZWu(e$558scjj$<`?}E?S<1FfE)<$2{ouPpKC*JDDHH!HrTYm1BlT;c!cYvYi(y zEzeyMV3rgDlp8f~+%u16@xNS22JG8gvqXA4Vr32SOvL~-uD?Rv&f_0y&YrxRW7JF` zqF5#F&MDsWD6Gst&;@Jl_c6mJvF>fqE=7q}<(CrBE|4zr>6_svEv(FbIQAZ>x^{rB z)7>*5yz!Nb1;(?_v+KLxA;*Gy%zHASn3!i}PX-;nw?UlN_Ogb9HqB#^T(p%g9;{3@ zb! z-)HSMfM0h7OC)nXS@+Z9Y#gh8PJRAx8DYrs|BZ77tdGp?Q{8rTp11rhzmxeM!;qI( zJ(#;dz8XHnIio(h841$7l+0XqobN>kDNv>BIWydvouyd55Zu5!d#HBxs;pFR{Pu~? zySq-!_c%w8^tru5D+76V$|et$b8lab&oe4(mDhZFM-|CzjmlVtiC7qp$qWbgk3|IF z2li;Hqjn~YcX~Pdv6?#a3l IUKm>gjscecLAB8N)$ePIE}}xiUeXZ5uX&VpU;7G zRfG$;<{KIjNK`deVHBuC>?SY71KA z;Ge`gUY>SscgqRoECZ31)N>yJZl9~tyfYIa$8)DLyav@y-4)icgwzGo1N2o2yee^f zP^SMp7N8BP=<({7N*?vPinHr2mDv?V&Ca?80DGw#ZLR~=iWS)c0g}w8#}G$vHs?=UUOwmL+8r*t^wJU&S3BY zHD62V7+g-mZlkHJQU&2uP{aJQL*2iRo?3zXuMg7y-97(WSIx6~V-{bG!W< zch9$N%?E`Fd_4o{DFo?bUgq)i8!_}7;PRHFzQKYFCUf*>&p12k z>305>+neK-f5h$lhcv3AQB?2MOqaZ&*`zKm3CuyNtiEeK*)UJuS`lv(Jw1yPT4J??=bTm5zv^joI}8 z)t&7sj+UYGrv@B=V19e900)K7MDT(5e1IK49#Ev2wbAv~dXoX0UJnT*h>DbFa7J-CYS)CTAaSN~e%>@mCS2 zIL{~D;iB_>HQ*Pjj+(WlRhb+$1Dn2aQ<`@ZvjkW4Mf_NwKH;X-S(gn(qGr~@lLcT;~4Om!b2OmIZ9!NSwuJ0cSwF}q@2rbX_~Db zKe+b~lYeuc0t=#9WoKl2I zVm0j+;Ig7o8s?@|4Gul;iD+=aoY(hd_^Qgv5|b6CC=Rso~?izf0#+@wVEXr;=^0%Nxx4p6FQ25_S|8?miAbd4a9Qb4Ao zZ0E$k^|hRR;jQohdeqb`I4-PGcmLd=7s^(9LMCz%Sd7Cn8+8+XL%sFl5_DFNKw5r= z(W4~A@e8bjWJ4S~yM0+S?=Sp>Q)m;q$w)^*SOGjPOyw$FVLrF`K1NF$QR(JtOs@tL z)nprM=jIU*|HNeKt7vTD)Xi}=d`03;PHX~Mvz$#@46(0uBNV4sU#h*4>XNidh5EpS zQ_h-u5x;vasbTL2q15F(tOEdtQMU+)0axPlrs|nPcXVW|(FIP8OC0flBJ%)x7`ULR z!$|4sgdd`7LMs^jDuO3pdZa0%abHjEHksPb#p~ALj5|BM*7<yzYvjMv$jRN>MJ3l7cgIv!#4gEt$c60kjKPG$OtAV3&HMheU{n<4xKQXJ1C^Zqf zzIq3jLg|<>r8O#(lA3|uf?6nR+S1rvb+4T4*S_<~a15g+ljEb;F>en|W=dM*w$*y<0mq|B>N$D&Xiqzdn%F-Q|p^|upOKoD2REpY(Wl~^KQI-ou-+&97)`v+f zZh=g^{?Xf-mJI~QY?yDECkZV-Pc57TCE6ixVyIn~Xu?dVFk7NCMY8Qt&9h6JxQ+2a zGQXr!S}vI)BJZ?&WK01DAW}Eln(qO;k}`^2gEHjMf@6-TFykjsAop2z1_(7V`1rN1 z%lVoMnG#K*mSizIqAQj{V<%e&9aISR5_s%n$KY>!Rt%W4@(3qIP3IQ1aFf07*p`70 zRd=&WMUwS4K`4qKzo1Y`i4%CXjGK!VWs4X#Ei8gBLJ$|xs}bx>u5`&i`R$VhQ-M*L z-7XPGPJB&zrJygoURRuGl(bBw5e~@KIf1F)kdtJKWa~WJPC2N6e zyJ$-ZSV;j!oKj9Ueen;2L!sNaIJzIz*$$UXn@J+^FaAb;>!NCc9Un=PQoPThUe8YN zg(`C0!?(^{1SmyV>ph}PvO^Ho@Y1}Th z_GD063j}hu%{kd>51k(`hEw>)^N(;FwPc(u#!HKN^YJBB0611@1l>;?MtepP+Q7}V zGqwGVXp-L>odmn&H=T)~)FYm40NI7~$5vy1Zw3F^b@JP1GEW2BjVasSxc5R8;1C^m zk|x#6h^f7K4z>Ki7cCeZuUsC4@_t`n?lTRhHC~>)9~i>mo4+xwU_kNW(tlg?ow`qv zAEPF`oEU@fTyF-SBbd>=9MRoWFnD_L`#<+~{nH%%YdQK6PLB*$6g2r-;$rH6>8_Ry zL~4L#|C5QSmJLDLk*St!Wj%XoalWh_QX+<%j*kWi{h%?JI?

BHV{=%#Qt0m4OU^ zxHLKE#?Fik^1&3VA;(T~=Bd+*^*x&-Di)Y|L;wjb^$Kv`PZ9(=Q$9$qB=k1bSGRDW zSl~GiO{pZGMHLM8 z@XXLa@WV{Baq@DMIB|)f6XVU)N@q*iz0`LG0%+R9L$PuPQe2m@xWc%x6waKTO`k|m zb)Sog&r`;XaW*}IzEma$9ZqrmJ)n+pN5_9rYbIPg90y`%IX2>Sg!N1tHv`e?4ZocW zoXXRNg>x(O*4d@9+ODq(a2Kz!yS1lM8eg~eGrzXdZP{5x-P)TjI5E2PF99)28vQDI zd`)O>!nkIpMp00m-vuL0?OrE%Qc|{oQwH?!t8Jt~3EtCIA9r+cCuA&(jw=Hi z{9@c#|Lr!ooz4C2L`@Ca2=`Il$k@`#HDUeyNQ$hKgdX!CjjjWtV(*QbafCNBC}cNv zD8Ml#g&2!&R{;}R(zc3nTSY@cB8`fyIbx^oL$9X`XDO(CI942Vyjn{P7sWt0HbO(T zIVUfSJ)EIC*F1Ds)R4n@`SMgSPXwX-`P$f-ah|mqRojKZ4R?lE7D>n0+yS7C%_Gvm`z3}83Qi$BTE3Pq`ro_sPj$;Ixnz1-NN(4MMHtY^U#jAV4 zY_*@0S2J#Wn&+wR8=l2Vo_x;M&RlB^Vz!Ys-t;u72WRPx65NbsMvGv{B^D%Z#DJl1$e5t2!q43>>>b2T9r zleH0Ijyte^wyFW{KgQ~t$jDk_v^L0u2P+ye;EZ!IKXRG+yefE{B?lpn#Z|pBK4(AM zLNlBFm*gto*H2(dU12;_pob?KfTK299=a1K5zG{ z+YJ1F@p~42{gp-NeV6i8^hd8!J$H?tEpNzq^-zP_t)xzhFb%S_=#9Tuk)JK6i0WRq ze&=&k`G(jgU+cfHk}>Y z!NhCinXYnd*$xYwP9>m0ebZXy{*ka0IwtocAVnxgw;t4l2JKOnwPM|UYbD}!1r)Rp zW^bh`uOlo&Gb=aIsN3Ckw%Z{pH5Jj6kT4jxg8o(RenChVR*AodY5!?#_MmtEEehP7 z%&(x;9*);d9;)!GMd0J{?Xo!Ob|qY*b^U~k~Q`L+S6^n zGL$6Hx}^&vHHw4FIJd+f%iVG8&a_r6*RDY1I&?LX7~9lNL`!fS&IP3$%Ow$&N+`Bx z)@)+EJuRiRwm>JJ8bWu)M6OTE($vAEbZjU+#-0>P$G6{Nr%qH^28N$Ut}7UmYt+_d z$2PE%CwkM+qhg|_&MT#x?K_;#KAgfq1U2F-CbmVw+f&Np_RQa)IWRwOHr7u zXCP$*b$%&yyv;cL-oWXyb`ohB`@<>b!BCYF4z=@e$upr3VYx&}D_*}sCQ}Ts^p-n2 zU~1R@>2M0.7 test + + + + org.springframework.kafka + spring-kafka + + diff --git a/kafka-webview-ui/src/main/frontend/js/app.js b/kafka-webview-ui/src/main/frontend/js/app.js index 89ab70f7..e98bc344 100755 --- a/kafka-webview-ui/src/main/frontend/js/app.js +++ b/kafka-webview-ui/src/main/frontend/js/app.js @@ -364,6 +364,20 @@ var ApiClient = { } }); }, + submitKafkaMessage: function(producerId, messageRequest, callback){ + jQuery.ajax({ + type: 'POST', + url: ApiClient.buildUrl('api/producer/' + producerId + '/send-message'), + data: messageRequest, + dataType: 'json', + headers: ApiClient.getCsrfHeader(), + success: callback, + error: ApiClient.defaultErrorHandler, + beforeSend: function(xhr) { + xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); + } + }); + }, defaultErrorHandler: function(jqXHR, textStatus, errorThrown) { // convert response to json var response = jQuery.parseJSON(jqXHR.responseText); diff --git a/kafka-webview-ui/src/main/frontend/package-lock.json b/kafka-webview-ui/src/main/frontend/package-lock.json index 50f7cc65..9eeca041 100644 --- a/kafka-webview-ui/src/main/frontend/package-lock.json +++ b/kafka-webview-ui/src/main/frontend/package-lock.json @@ -969,7 +969,6 @@ "anymatch": "2.0.0", "async-each": "1.0.1", "braces": "2.3.2", - "fsevents": "1.2.4", "glob-parent": "3.1.0", "inherits": "2.0.3", "is-binary-path": "1.0.1", @@ -2113,542 +2112,6 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "fsevents": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", - "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", - "dev": true, - "optional": true, - "requires": { - "nan": "2.11.1", - "node-pre-gyp": "0.10.0" - }, - "dependencies": { - "abbrev": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "ansi-regex": { - "version": "2.1.1", - "bundled": true, - "dev": true - }, - "aproba": { - "version": "1.2.0", - "bundled": true, - "dev": true, - "optional": true - }, - "are-we-there-yet": { - "version": "1.1.4", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "delegates": "1.0.0", - "readable-stream": "2.3.6" - } - }, - "balanced-match": { - "version": "1.0.0", - "bundled": true, - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "bundled": true, - "dev": true, - "requires": { - "balanced-match": "1.0.0", - "concat-map": "0.0.1" - } - }, - "chownr": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "code-point-at": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "bundled": true, - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "bundled": true, - "dev": true - }, - "core-util-is": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "debug": { - "version": "2.6.9", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "ms": "2.0.0" - } - }, - "deep-extend": { - "version": "0.5.1", - "bundled": true, - "dev": true, - "optional": true - }, - "delegates": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "detect-libc": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "optional": true - }, - "fs-minipass": { - "version": "1.2.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minipass": "2.2.4" - } - }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "gauge": { - "version": "2.7.4", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "aproba": "1.2.0", - "console-control-strings": "1.1.0", - "has-unicode": "2.0.1", - "object-assign": "4.1.1", - "signal-exit": "3.0.2", - "string-width": "1.0.2", - "strip-ansi": "3.0.1", - "wide-align": "1.1.2" - } - }, - "glob": { - "version": "7.1.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.3", - "minimatch": "3.0.4", - "once": "1.4.0", - "path-is-absolute": "1.0.1" - } - }, - "has-unicode": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "iconv-lite": { - "version": "0.4.21", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safer-buffer": "2.1.2" - } - }, - "ignore-walk": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minimatch": "3.0.4" - } - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "once": "1.4.0", - "wrappy": "1.0.2" - } - }, - "inherits": { - "version": "2.0.3", - "bundled": true, - "dev": true - }, - "ini": { - "version": "1.3.5", - "bundled": true, - "dev": true, - "optional": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "requires": { - "number-is-nan": "1.0.1" - } - }, - "isarray": { - "version": "1.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "minimatch": { - "version": "3.0.4", - "bundled": true, - "dev": true, - "requires": { - "brace-expansion": "1.1.11" - } - }, - "minimist": { - "version": "0.0.8", - "bundled": true, - "dev": true - }, - "minipass": { - "version": "2.2.4", - "bundled": true, - "dev": true, - "requires": { - "safe-buffer": "5.1.1", - "yallist": "3.0.2" - } - }, - "minizlib": { - "version": "1.1.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "minipass": "2.2.4" - } - }, - "mkdirp": { - "version": "0.5.1", - "bundled": true, - "dev": true, - "requires": { - "minimist": "0.0.8" - } - }, - "ms": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "nan": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.1.tgz", - "integrity": "sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==", - "dev": true, - "optional": true - }, - "needle": { - "version": "2.2.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "debug": "2.6.9", - "iconv-lite": "0.4.21", - "sax": "1.2.4" - } - }, - "node-pre-gyp": { - "version": "0.10.0", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "detect-libc": "1.0.3", - "mkdirp": "0.5.1", - "needle": "2.2.0", - "nopt": "4.0.1", - "npm-packlist": "1.1.10", - "npmlog": "4.1.2", - "rc": "1.2.7", - "rimraf": "2.6.2", - "semver": "5.5.0", - "tar": "4.4.1" - } - }, - "nopt": { - "version": "4.0.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "abbrev": "1.1.1", - "osenv": "0.1.5" - } - }, - "npm-bundled": { - "version": "1.0.3", - "bundled": true, - "dev": true, - "optional": true - }, - "npm-packlist": { - "version": "1.1.10", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "ignore-walk": "3.0.1", - "npm-bundled": "1.0.3" - } - }, - "npmlog": { - "version": "4.1.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "are-we-there-yet": "1.1.4", - "console-control-strings": "1.1.0", - "gauge": "2.7.4", - "set-blocking": "2.0.0" - } - }, - "number-is-nan": { - "version": "1.0.1", - "bundled": true, - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "bundled": true, - "dev": true, - "optional": true - }, - "once": { - "version": "1.4.0", - "bundled": true, - "dev": true, - "requires": { - "wrappy": "1.0.2" - } - }, - "os-homedir": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "os-tmpdir": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "osenv": { - "version": "0.1.5", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "os-homedir": "1.0.2", - "os-tmpdir": "1.0.2" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "process-nextick-args": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "rc": { - "version": "1.2.7", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "deep-extend": "0.5.1", - "ini": "1.3.5", - "minimist": "1.2.0", - "strip-json-comments": "2.0.1" - }, - "dependencies": { - "minimist": { - "version": "1.2.0", - "bundled": true, - "dev": true, - "optional": true - } - } - }, - "readable-stream": { - "version": "2.3.6", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "core-util-is": "1.0.2", - "inherits": "2.0.3", - "isarray": "1.0.0", - "process-nextick-args": "2.0.0", - "safe-buffer": "5.1.1", - "string_decoder": "1.1.1", - "util-deprecate": "1.0.2" - } - }, - "rimraf": { - "version": "2.6.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "glob": "7.1.2" - } - }, - "safe-buffer": { - "version": "5.1.1", - "bundled": true, - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "bundled": true, - "dev": true, - "optional": true - }, - "sax": { - "version": "1.2.4", - "bundled": true, - "dev": true, - "optional": true - }, - "semver": { - "version": "5.5.0", - "bundled": true, - "dev": true, - "optional": true - }, - "set-blocking": { - "version": "2.0.0", - "bundled": true, - "dev": true, - "optional": true - }, - "signal-exit": { - "version": "3.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "string-width": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "requires": { - "code-point-at": "1.1.0", - "is-fullwidth-code-point": "1.0.0", - "strip-ansi": "3.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "safe-buffer": "5.1.1" - } - }, - "strip-ansi": { - "version": "3.0.1", - "bundled": true, - "dev": true, - "requires": { - "ansi-regex": "2.1.1" - } - }, - "strip-json-comments": { - "version": "2.0.1", - "bundled": true, - "dev": true, - "optional": true - }, - "tar": { - "version": "4.4.1", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "chownr": "1.0.1", - "fs-minipass": "1.2.5", - "minipass": "2.2.4", - "minizlib": "1.1.0", - "mkdirp": "0.5.1", - "safe-buffer": "5.1.1", - "yallist": "3.0.2" - } - }, - "util-deprecate": { - "version": "1.0.2", - "bundled": true, - "dev": true, - "optional": true - }, - "wide-align": { - "version": "1.1.2", - "bundled": true, - "dev": true, - "optional": true, - "requires": { - "string-width": "1.0.2" - } - }, - "wrappy": { - "version": "1.0.2", - "bundled": true, - "dev": true - }, - "yallist": { - "version": "3.0.2", - "bundled": true, - "dev": true - } - } - }, "fstream": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/DataLoaderConfig.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/DataLoaderConfig.java index 5570d86c..5ed1260b 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/DataLoaderConfig.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/DataLoaderConfig.java @@ -24,6 +24,7 @@ package org.sourcelab.kafka.webview.ui.configuration; +import org.apache.kafka.clients.producer.internals.DefaultPartitioner; import org.apache.kafka.common.serialization.ByteArrayDeserializer; import org.apache.kafka.common.serialization.BytesDeserializer; import org.apache.kafka.common.serialization.DoubleDeserializer; @@ -35,8 +36,10 @@ import org.sourcelab.kafka.webview.ui.manager.kafka.deserializer.BytesToHexDeserializer; import org.sourcelab.kafka.webview.ui.manager.user.UserManager; import org.sourcelab.kafka.webview.ui.model.MessageFormat; +import org.sourcelab.kafka.webview.ui.model.PartitioningStrategy; import org.sourcelab.kafka.webview.ui.model.UserRole; import org.sourcelab.kafka.webview.ui.repository.MessageFormatRepository; +import org.sourcelab.kafka.webview.ui.repository.PartitioningStrategyRepository; import org.sourcelab.kafka.webview.ui.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; @@ -54,14 +57,20 @@ public final class DataLoaderConfig implements ApplicationRunner { private final MessageFormatRepository messageFormatRepository; private final UserRepository userRepository; + private final PartitioningStrategyRepository partitioningStrategyRepository; /** * Constructor. */ @Autowired - private DataLoaderConfig(final MessageFormatRepository messageFormatRepository, final UserRepository userRepository) { + private DataLoaderConfig( + final MessageFormatRepository messageFormatRepository, + final UserRepository userRepository, + final PartitioningStrategyRepository partitioningStrategyRepository + ) { this.messageFormatRepository = messageFormatRepository; this.userRepository = userRepository; + this.partitioningStrategyRepository = partitioningStrategyRepository; } /** @@ -70,6 +79,7 @@ private DataLoaderConfig(final MessageFormatRepository messageFormatRepository, private void createData() { createDefaultUser(); createDefaultMessageFormats(); + createDefaultPartitioningStrategies(); } /** @@ -119,11 +129,32 @@ private void createDefaultMessageFormats() { } } + /** + * Creates default partitioning strategies. + */ + private void createDefaultPartitioningStrategies() { + final Map defaultEntries = new HashMap<>(); + defaultEntries.put("Default Partitioner", DefaultPartitioner.class.getName()); + + // Create if needed. + for (final Map.Entry entry : defaultEntries.entrySet()) { + PartitioningStrategy partitioningStrategy = partitioningStrategyRepository.findByName(entry.getKey()); + if (partitioningStrategy == null) { + partitioningStrategy = new PartitioningStrategy(); + } + partitioningStrategy.setName(entry.getKey()); + partitioningStrategy.setClasspath(entry.getValue()); + partitioningStrategy.setJar("n/a"); + partitioningStrategy.setDefault(true); + partitioningStrategyRepository.save(partitioningStrategy); + } + } + /** * Run on startup. */ @Override - public void run(final ApplicationArguments args) throws Exception { + public void run(final ApplicationArguments args) { createData(); } } diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/PluginConfig.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/PluginConfig.java index 2c4cdf48..82980d78 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/PluginConfig.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/configuration/PluginConfig.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.hubspot.jackson.datatype.protobuf.ProtobufModule; +import org.apache.kafka.clients.producer.Partitioner; import org.apache.kafka.common.serialization.Deserializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -87,6 +88,17 @@ public PluginFactory getRecordFilterPluginFactory(final AppPropert return new PluginFactory<>(jarDirectory, RecordFilter.class); } + /** + * PluginFactory for creating instances of Partitioners. + * @param appProperties Definition of app properties. + * @return PluginFactory for Partitioners. + */ + @Bean + public PluginFactory getPartitionerPluginFactory(final AppProperties appProperties) { + final String jarDirectory = appProperties.getUploadPath() + "/partitioners"; + return new PluginFactory<>(jarDirectory, Partitioner.class); + } + /** * For handling secrets, symmetrical encryption. * @param appProperties Definition of app properties. diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/BaseController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/BaseController.java index d087f2f7..d02e3c58 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/BaseController.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/BaseController.java @@ -27,8 +27,10 @@ import org.sourcelab.kafka.webview.ui.configuration.AppProperties; import org.sourcelab.kafka.webview.ui.manager.user.CustomUserDetails; import org.sourcelab.kafka.webview.ui.model.Cluster; +import org.sourcelab.kafka.webview.ui.model.Producer; import org.sourcelab.kafka.webview.ui.model.View; import org.sourcelab.kafka.webview.ui.repository.ClusterRepository; +import org.sourcelab.kafka.webview.ui.repository.ProducerRepository; import org.sourcelab.kafka.webview.ui.repository.ViewRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AnonymousAuthenticationToken; @@ -52,6 +54,9 @@ public abstract class BaseController { @Autowired private ViewRepository viewRepository; + @Autowired + private ProducerRepository producerRepository; + @Autowired private AppProperties appProperties; @@ -108,10 +113,12 @@ public void addAttributes(Model model) { // TODO put a limit on these final Iterable clusters = clusterRepository.findAllByOrderByNameAsc(); final Iterable views = viewRepository.findAllByOrderByNameAsc(); + final Iterable producers = producerRepository.findAllByOrderByNameAsc(); model.addAttribute("MenuClusters", clusters); model.addAttribute("MenuViews", views); model.addAttribute("UserId", getLoggedInUserId()); + model.addAttribute( "MenuProducers", producers ); if (!appProperties.isUserAuthEnabled() || appProperties.getLdapProperties().isEnabled()) { model.addAttribute("MenuShowUserConfig", false); diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/ApiController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/ApiController.java index 1c2f4335..0a5614fd 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/ApiController.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/ApiController.java @@ -24,21 +24,13 @@ package org.sourcelab.kafka.webview.ui.controller.api; +import org.apache.kafka.clients.producer.ProducerRecord; import org.sourcelab.kafka.webview.ui.controller.BaseController; import org.sourcelab.kafka.webview.ui.controller.api.exceptions.ApiException; import org.sourcelab.kafka.webview.ui.controller.api.exceptions.NotFoundApiException; -import org.sourcelab.kafka.webview.ui.controller.api.requests.ConsumeRequest; -import org.sourcelab.kafka.webview.ui.controller.api.requests.ConsumerRemoveRequest; -import org.sourcelab.kafka.webview.ui.controller.api.requests.CreateTopicRequest; -import org.sourcelab.kafka.webview.ui.controller.api.requests.DeleteTopicRequest; -import org.sourcelab.kafka.webview.ui.controller.api.requests.ModifyTopicConfigRequest; +import org.sourcelab.kafka.webview.ui.controller.api.requests.*; import org.sourcelab.kafka.webview.ui.controller.api.responses.ResultResponse; -import org.sourcelab.kafka.webview.ui.manager.kafka.KafkaOperations; -import org.sourcelab.kafka.webview.ui.manager.kafka.KafkaOperationsFactory; -import org.sourcelab.kafka.webview.ui.manager.kafka.SessionIdentifier; -import org.sourcelab.kafka.webview.ui.manager.kafka.ViewCustomizer; -import org.sourcelab.kafka.webview.ui.manager.kafka.WebKafkaConsumer; -import org.sourcelab.kafka.webview.ui.manager.kafka.WebKafkaConsumerFactory; +import org.sourcelab.kafka.webview.ui.manager.kafka.*; import org.sourcelab.kafka.webview.ui.manager.kafka.config.FilterDefinition; import org.sourcelab.kafka.webview.ui.manager.kafka.dto.ApiErrorResponse; import org.sourcelab.kafka.webview.ui.manager.kafka.dto.ConfigItem; @@ -55,34 +47,20 @@ import org.sourcelab.kafka.webview.ui.manager.kafka.dto.TopicDetails; import org.sourcelab.kafka.webview.ui.manager.kafka.dto.TopicList; import org.sourcelab.kafka.webview.ui.manager.kafka.dto.TopicListing; -import org.sourcelab.kafka.webview.ui.model.Cluster; -import org.sourcelab.kafka.webview.ui.model.Filter; -import org.sourcelab.kafka.webview.ui.model.View; -import org.sourcelab.kafka.webview.ui.repository.ClusterRepository; -import org.sourcelab.kafka.webview.ui.repository.FilterRepository; -import org.sourcelab.kafka.webview.ui.repository.ViewRepository; +import org.sourcelab.kafka.webview.ui.model.*; +import org.sourcelab.kafka.webview.ui.repository.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.ResponseStatus; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import org.springframework.util.concurrent.ListenableFuture; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.concurrent.ExecutionException; /** * Handles API requests. @@ -99,6 +77,9 @@ public class ApiController extends BaseController { @Autowired private FilterRepository filterRepository; + @Autowired + private ProducerRepository producerRepository; + @Autowired private WebKafkaConsumerFactory webKafkaConsumerFactory; @@ -603,6 +584,38 @@ public boolean removeConsumer( } } + /** + * POST put a message on kafka bus + */ + @ResponseBody + @PostMapping(path="/producer/{id}/send-message", produces = "application/json") + public void sendMessage(@PathVariable final Long id, @RequestBody final SendMessageRequest request ) + { + + final Producer producer = producerRepository.findById( id ).orElseThrow( () -> new NotFoundApiException( "Producer", "Unable to find producer" ) ); + final Cluster cluster = clusterRepository.findById( producer.getCluster().getId() ).orElseThrow( () -> new NotFoundApiException( "Producer", "Unable to find cluster" ) ); + + + Map producerFactoryConfigs = new HashMap<>(); + producerFactoryConfigs.put( "bootstrap.servers", cluster.getBrokerHosts() ); + producerFactoryConfigs.put( "key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); + producerFactoryConfigs.put( "value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); + + KafkaTemplate template = new KafkaTemplate<>( new DefaultKafkaProducerFactory<>( producerFactoryConfigs ) ); + + ProducerRecord record = new ProducerRecord<>( producer.getTopic(), UUID.randomUUID().toString(), request.getMessageAsJson() ); + + ListenableFuture> future = template.send( record ); + + try + { + future.get(); // ensure it was successful. will throw exception otherwise + } catch( ExecutionException | InterruptedException e ) + { + //pfffft????? + } + } + /** * Error handler for ApiExceptions. */ diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/requests/SendMessageRequest.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/requests/SendMessageRequest.java new file mode 100644 index 00000000..8b929ec6 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/api/requests/SendMessageRequest.java @@ -0,0 +1,60 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.controller.api.requests; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Map; + +public class SendMessageRequest +{ + private Map messageMap; + + public Map getMessageMap() + { + return messageMap; + } + + public void setMessageMap( Map messageMap ) + { + this.messageMap = messageMap; + } + + public String getMessageAsJson() + { + String result = ""; + ObjectMapper mapper = new ObjectMapper(); + try + { + result = mapper.writeValueAsString( messageMap ); + }catch ( JsonProcessingException e ) + { + //lol oops + } + + return result; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/MessageFormatController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/MessageFormatController.java index 376bec2e..46143f38 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/MessageFormatController.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/MessageFormatController.java @@ -24,16 +24,13 @@ package org.sourcelab.kafka.webview.ui.controller.configuration.messageformat; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.common.serialization.Deserializer; import org.sourcelab.kafka.webview.ui.controller.BaseController; import org.sourcelab.kafka.webview.ui.controller.configuration.messageformat.forms.MessageFormatForm; +import org.sourcelab.kafka.webview.ui.manager.controller.EntityUsageManager; +import org.sourcelab.kafka.webview.ui.manager.controller.UploadableJarControllerHelper; import org.sourcelab.kafka.webview.ui.manager.plugin.PluginFactory; import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; -import org.sourcelab.kafka.webview.ui.manager.plugin.exception.LoaderException; -import org.sourcelab.kafka.webview.ui.manager.ui.BreadCrumbManager; -import org.sourcelab.kafka.webview.ui.manager.ui.FlashMessage; import org.sourcelab.kafka.webview.ui.model.MessageFormat; import org.sourcelab.kafka.webview.ui.model.View; import org.sourcelab.kafka.webview.ui.repository.MessageFormatRepository; @@ -42,24 +39,14 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import javax.validation.Valid; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; import java.util.Map; -import java.util.Optional; /** * Controller for MessageFormat CRUD operations. @@ -85,31 +72,15 @@ public class MessageFormatController extends BaseController { */ @RequestMapping(path = "", method = RequestMethod.GET) public String index(final Model model) { - // Setup breadcrumbs - setupBreadCrumbs(model, null, null); - - // Retrieve all default formats - final Iterable defaultMessageFormats = messageFormatRepository.findByIsDefaultFormatOrderByNameAsc(true); - - // Retrieve all custom formats - final Iterable customMessageFormats = messageFormatRepository.findByIsDefaultFormatOrderByNameAsc(false); - - // Set view attributes - model.addAttribute("defaultMessageFormats", defaultMessageFormats); - model.addAttribute("customMessageFormats", customMessageFormats); - - return "configuration/messageFormat/index"; + return getHelper().buildIndex(model); } /** * GET Displays create message format form. */ @RequestMapping(path = "/create", method = RequestMethod.GET) - public String createMessageFormat(final MessageFormatForm messageFormatForm, final Model model) { - // Setup breadcrumbs - setupBreadCrumbs(model, "Create", null); - - return "configuration/messageFormat/create"; + public String createMessageFormat(final MessageFormatForm form, final Model model) { + return getHelper().buildCreate(model); } /** @@ -118,43 +89,11 @@ public String createMessageFormat(final MessageFormatForm messageFormatForm, fin @RequestMapping(path = "/edit/{id}", method = RequestMethod.GET) public String editMessageFormat( @PathVariable final Long id, - final MessageFormatForm messageFormatForm, + final MessageFormatForm form, final Model model, final RedirectAttributes redirectAttributes) { - // Retrieve it - final Optional messageFormatOptional = messageFormatRepository.findById(id); - if (!messageFormatOptional.isPresent()) { - // Set flash message & redirect - redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newWarning("Unable to find message format!")); - return "redirect:/configuration/messageFormat"; - } - final MessageFormat messageFormat = messageFormatOptional.get(); - - // Setup breadcrumbs - setupBreadCrumbs(model, "Edit " + messageFormat.getName(), null); - - // Setup form - messageFormatForm.setId(messageFormat.getId()); - messageFormatForm.setName(messageFormat.getName()); - messageFormatForm.setClasspath(messageFormat.getClasspath()); - - // Deserialize message parameters json string into a map - final ObjectMapper objectMapper = new ObjectMapper(); - Map customOptions; - try { - customOptions = objectMapper.readValue(messageFormat.getOptionParameters(), Map.class); - } catch (final IOException e) { - // Fail safe? - customOptions = new HashMap<>(); - } - - // Update form object with properties. - for (final Map.Entry entry : customOptions.entrySet()) { - messageFormatForm.getCustomOptionNames().add(entry.getKey()); - messageFormatForm.getCustomOptionValues().add(entry.getValue()); - } - return "configuration/messageFormat/create"; + return getHelper().buildEdit(id, form, model, redirectAttributes); } /** @@ -172,128 +111,13 @@ public String editMessageFormat( */ @RequestMapping(path = "/update", method = RequestMethod.POST) public String create( - @Valid final MessageFormatForm messageFormatForm, + @Valid final MessageFormatForm form, final BindingResult bindingResult, final RedirectAttributes redirectAttributes, @RequestParam final Map allRequestParams) { - // If we have errors just display the form again. - if (bindingResult.hasErrors()) { - return "configuration/messageFormat/create"; - } - - // Grab uploaded file - final MultipartFile file = messageFormatForm.getFile(); - - // If the message format doesn't exist, and no file uploaded. - if (!messageFormatForm.exists() && file.isEmpty()) { - bindingResult.addError(new FieldError( - "messageFormatForm", "file", "", true, null, null, "Select a jar to upload") - ); - return "configuration/messageFormat/create"; - } - - // If filter exists - final MessageFormat messageFormat; - if (messageFormatForm.exists()) { - // Retrieve message format - final Optional messageFormatOptional = messageFormatRepository.findById(messageFormatForm.getId()); - - // If we can't find the format - if (!messageFormatOptional.isPresent()) { - // Set flash message & redirect - redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newWarning("Unable to find message format!")); - return "redirect:/configuration/messageFormat"; - } - messageFormat = messageFormatOptional.get(); - } else { - // Creating new message format - messageFormat = new MessageFormat(); - } - - // Handle custom options, convert into a JSON string. - final String jsonStr = handleCustomOptions(messageFormatForm); - - // If we have a new file uploaded. - if (!file.isEmpty()) { - try { - // Sanitize file name. - final String newFilename = messageFormatForm.getName().replaceAll("[^A-Za-z0-9]", "_") + ".jar"; - final String tempFilename = newFilename + ".tmp"; - - // Persist jar on filesystem in a temporary location - final String jarPath = uploadManager.handleDeserializerUpload(file, tempFilename); - - // Attempt to load jar? - try { - deserializerLoader.checkPlugin(tempFilename, messageFormatForm.getClasspath()); - } catch (final LoaderException exception) { - // If we had issues, remove the temp location - Files.delete(Paths.get(jarPath)); - - // Add an error - bindingResult.addError(new FieldError( - "messageFormatForm", "file", "", true, null, null, exception.getMessage()) - ); - // And re-display the form. - return "configuration/messageFormat/create"; - } - // Ok new JAR looks good. - // 1 - remove pre-existing jar if it exists - if (messageFormat.getJar() != null && !messageFormat.getJar().isEmpty()) { - // Delete pre-existing jar. - Files.deleteIfExists(deserializerLoader.getPathForJar(messageFormat.getJar())); - } - - // 2 - move tempFilename => filename. - // Lets just delete the temp path and re-handle the upload. - Files.deleteIfExists(Paths.get(jarPath)); - uploadManager.handleDeserializerUpload(file, newFilename); - - // 3 - Update the jar and class path properties. - messageFormat.setJar(newFilename); - messageFormat.setClasspath(messageFormatForm.getClasspath()); - } catch (final IOException e) { - // Set flash message - redirectAttributes.addFlashAttribute("exception", e.getMessage()); - redirectAttributes.addFlashAttribute( - "FlashMessage", - FlashMessage.newWarning("Unable to save uploaded JAR: " + e.getMessage())); - - // redirect to cluster index - return "redirect:/configuration/messageFormat"; - } - } - - // If we made it here, write MessageFormat entity. - messageFormat.setName(messageFormatForm.getName()); - messageFormat.setDefaultFormat(false); - messageFormat.setOptionParameters(jsonStr); - messageFormatRepository.save(messageFormat); - - redirectAttributes.addFlashAttribute( - "FlashMessage", - FlashMessage.newSuccess("Successfully created message format!")); - return "redirect:/configuration/messageFormat"; - } - - /** - * Handles getting custom defined options and values. - * @param messageFormatForm The submitted form. - */ - private String handleCustomOptions(final MessageFormatForm messageFormatForm) { - // Build a map of Name => Value - final Map mappedOptions = messageFormatForm.getCustomOptionsAsMap(); - - // For converting map to json string - final ObjectMapper objectMapper = new ObjectMapper(); - - try { - return objectMapper.writeValueAsString(mappedOptions); - } catch (final JsonProcessingException e) { - // Fail safe? - return "{}"; - } + return getHelper() + .handleUpdate(form, bindingResult, redirectAttributes); } /** @@ -301,69 +125,28 @@ private String handleCustomOptions(final MessageFormatForm messageFormatForm) { */ @RequestMapping(path = "/delete/{id}", method = RequestMethod.POST) public String deleteCluster(@PathVariable final Long id, final RedirectAttributes redirectAttributes) { - // Where to redirect. - final String redirectUrl = "redirect:/configuration/messageFormat"; - // Retrieve it - final Optional messageFormatOptional = messageFormatRepository.findById(id); - if (!messageFormatOptional.isPresent() || messageFormatOptional.get().isDefaultFormat()) { - // Set flash message & redirect - redirectAttributes.addFlashAttribute( - "FlashMessage", - FlashMessage.newWarning("Unable to remove message format!")); - return redirectUrl; - } - final MessageFormat messageFormat = messageFormatOptional.get(); + return getHelper().processDelete(id, redirectAttributes, entityId -> { + final Iterable views = + viewRepository.findAllByKeyMessageFormatIdOrValueMessageFormatIdOrderByNameAsc(entityId, entityId); - // See if its in use by any views - final Iterable views = viewRepository - .findAllByKeyMessageFormatIdOrValueMessageFormatIdOrderByNameAsc(messageFormat.getId(), messageFormat.getId()); - final Collection viewNames = new ArrayList<>(); - for (final View view: views) { - viewNames.add(view.getName()); - } - if (!viewNames.isEmpty()) { - // Set flash message & redirect - redirectAttributes.addFlashAttribute( - "FlashMessage", - FlashMessage.newWarning("Message format in use by views: " + viewNames.toString())); - return redirectUrl; - } - - try { - // Delete entity - messageFormatRepository.deleteById(id); - - // Delete jar from disk - try { - Files.deleteIfExists(deserializerLoader.getPathForJar(messageFormat.getJar())); - } catch (final NoSuchFileException exception) { - // swallow. + final EntityUsageManager.UsageBuilder builder = EntityUsageManager.Usage.newBuilder(); + for (final View view: views) { + builder.withInstance("View", view.getName(), view.getId()); } - redirectAttributes.addFlashAttribute( - "FlashMessage", - FlashMessage.newSuccess("Deleted message format!")); - } catch (final IOException e) { - redirectAttributes.addFlashAttribute( - "FlashMessage", - FlashMessage.newWarning("Unable to remove message format! " + e.getMessage())); - return redirectUrl; - } - - // redirect to cluster index - return redirectUrl; + return builder.build(); + }); } - private void setupBreadCrumbs(final Model model, final String name, final String url) { - // Setup breadcrumbs - final BreadCrumbManager manager = new BreadCrumbManager(model) - .addCrumb("Configuration", "/configuration"); - - if (name != null) { - manager.addCrumb("Message Formats", "/configuration/messageFormat"); - manager.addCrumb(name, url); - } else { - manager.addCrumb("Message Formats", null); - } + private UploadableJarControllerHelper getHelper() { + return new UploadableJarControllerHelper<>( + "Message Format", + "Message Formats", + "configuration/messageFormat", + MessageFormat.class, + uploadManager, + deserializerLoader, + messageFormatRepository + ); } } diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/forms/MessageFormatForm.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/forms/MessageFormatForm.java index c32b9cf5..c12f2123 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/forms/MessageFormatForm.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/messageformat/forms/MessageFormatForm.java @@ -24,129 +24,10 @@ package org.sourcelab.kafka.webview.ui.controller.configuration.messageformat.forms; -import org.springframework.web.multipart.MultipartFile; - -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import org.sourcelab.kafka.webview.ui.manager.controller.UploadableJarForm; /** * Represents the form to create/update a MessageFormat. */ -public class MessageFormatForm { - private Long id = null; - - @NotNull(message = "Enter a unique name") - @Size(min = 2, max = 255) - private String name; - - @NotNull(message = "Enter a classpath") - @Size(min = 2, max = 1024) - private String classpath; - - private MultipartFile file; - - /** - * Names of custom options. - */ - private List customOptionNames = new ArrayList<>(); - - /** - * Values of custom options. - */ - private List customOptionValues = new ArrayList<>(); - - public Long getId() { - return id; - } - - public void setId(final Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - public String getClasspath() { - return classpath; - } - - public void setClasspath(final String classpath) { - this.classpath = classpath; - } - - public MultipartFile getFile() { - return file; - } - - public void setFile(final MultipartFile file) { - this.file = file; - } - - public List getCustomOptionNames() { - return customOptionNames; - } - - public void setCustomOptionNames(final List customOptionNames) { - this.customOptionNames = customOptionNames; - } - - public List getCustomOptionValues() { - return customOptionValues; - } - - public void setCustomOptionValues(final List customOptionValues) { - this.customOptionValues = customOptionValues; - } - - /** - * Utility method to return custom options as a map. - */ - public Map getCustomOptionsAsMap() { - // Build a map of Name => Value - final Map mappedOptions = new HashMap<>(); - - final Iterator names = getCustomOptionNames().iterator(); - final Iterator values = getCustomOptionValues().iterator(); - - while (names.hasNext()) { - final String name = names.next(); - final String value; - if (values.hasNext()) { - value = values.next(); - } else { - value = ""; - } - mappedOptions.put(name, value); - } - return mappedOptions; - } - - /** - * Does the MessageFormat that this form represents already exist in the database. - */ - public boolean exists() { - return getId() != null; - } - - @Override - public String toString() { - return "MessageFormatForm{" - + "id=" + id - + ", name='" + name + '\'' - + ", classpath='" + classpath + '\'' - + ", file=" + file - + ", customOptionNames=" + customOptionNames - + ", customOptionValues=" + customOptionValues - + '}'; - } +public class MessageFormatForm extends UploadableJarForm { } diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/PartitionStrategyController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/PartitionStrategyController.java new file mode 100644 index 00000000..c66c81da --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/PartitionStrategyController.java @@ -0,0 +1,138 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.controller.configuration.partitioningstrategy; + +import org.apache.kafka.clients.producer.Partitioner; +import org.sourcelab.kafka.webview.ui.controller.BaseController; +import org.sourcelab.kafka.webview.ui.controller.configuration.partitioningstrategy.forms.PartitioningStrategyForm; +import org.sourcelab.kafka.webview.ui.manager.controller.UploadableJarControllerHelper; +import org.sourcelab.kafka.webview.ui.manager.plugin.PluginFactory; +import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; +import org.sourcelab.kafka.webview.ui.model.PartitioningStrategy; +import org.sourcelab.kafka.webview.ui.repository.PartitioningStrategyRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import javax.validation.Valid; +import java.util.Collections; +import java.util.Map; + +/** + * Controller for Partitioning Strategy CRUD operations. + */ +@Controller +@RequestMapping("/configuration/partitionStrategy") +public class PartitionStrategyController extends BaseController { + + @Autowired + private UploadManager uploadManager; + + @Autowired + private PluginFactory partitionerLoader; + + @Autowired + private PartitioningStrategyRepository partitioningStrategyRepository; + + /** + * GET Displays main message format index. + */ + @RequestMapping(path = "", method = RequestMethod.GET) + public String index(final Model model) { + return getHelper().buildIndex(model); + } + + /** + * GET Displays create partitioning strategy form. + */ + @RequestMapping(path = "/create", method = RequestMethod.GET) + public String createPartitionStrategy(final PartitioningStrategyForm form, final Model model) { + return getHelper().buildCreate(model); + } + + /** + * GET Displays edit partitioning strategy form. + */ + @RequestMapping(path = "/edit/{id}", method = RequestMethod.GET) + public String editPartitionStrategy( + @PathVariable final Long id, + final PartitioningStrategyForm form, + final Model model, + final RedirectAttributes redirectAttributes) { + + return getHelper() + .buildEdit(id, form, model, redirectAttributes); + } + + /** + * POST create or edit existing Partitioning Strategy. + * + * If the partitioning strategy does NOT yet exist: + * - Require a valid JAR + Classpath to be uploaded + * + * If the partitioning strategy DOES exist + * - If no jar is uploaded, only allow updating the name + options + * - If jar is uploaded, validate JAR + Classpath + * - If valid, replace existing Jar + * - If not valid, keep existing Jar. + * + */ + @RequestMapping(path = "/update", method = RequestMethod.POST) + public String create( + @Valid final PartitioningStrategyForm form, + final BindingResult bindingResult, + final RedirectAttributes redirectAttributes, + @RequestParam final Map allRequestParams + ) { + return getHelper().handleUpdate(form, bindingResult, redirectAttributes); + } + + /** + * POST deletes the selected partitioning strategy. + */ + @RequestMapping(path = "/delete/{id}", method = RequestMethod.POST) + public String deletePartitioningStrategy(@PathVariable final Long id, final RedirectAttributes redirectAttributes) { + return getHelper() + .processDelete(id, redirectAttributes, entityId -> Collections.emptyList()); + } + + private UploadableJarControllerHelper getHelper() { + return new UploadableJarControllerHelper<>( + "Partitioning Strategy", + "Partitioning Strategies", + "configuration/partitionStrategy", + PartitioningStrategy.class, + uploadManager, + partitionerLoader, + partitioningStrategyRepository + ); + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/forms/PartitioningStrategyForm.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/forms/PartitioningStrategyForm.java new file mode 100644 index 00000000..b333be74 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/partitioningstrategy/forms/PartitioningStrategyForm.java @@ -0,0 +1,34 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.controller.configuration.partitioningstrategy.forms; + +import org.sourcelab.kafka.webview.ui.manager.controller.UploadableJarForm; + +/** + * Represents the form to create/update a PartitioningStrategy. + */ +public class PartitioningStrategyForm extends UploadableJarForm { + +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/ProducerConfigController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/ProducerConfigController.java new file mode 100644 index 00000000..fab0f545 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/ProducerConfigController.java @@ -0,0 +1,257 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.controller.configuration.producer; + +import org.sourcelab.kafka.webview.ui.controller.BaseController; +import org.sourcelab.kafka.webview.ui.controller.configuration.producer.forms.ProducerForm; +import org.sourcelab.kafka.webview.ui.manager.kafka.KafkaOperations; +import org.sourcelab.kafka.webview.ui.manager.kafka.KafkaOperationsFactory; +import org.sourcelab.kafka.webview.ui.manager.kafka.dto.TopicDetails; +import org.sourcelab.kafka.webview.ui.manager.kafka.dto.TopicList; +import org.sourcelab.kafka.webview.ui.manager.ui.BreadCrumbManager; +import org.sourcelab.kafka.webview.ui.manager.ui.FlashMessage; +import org.sourcelab.kafka.webview.ui.model.Cluster; +import org.sourcelab.kafka.webview.ui.model.Producer; +import org.sourcelab.kafka.webview.ui.model.ProducerMessage; +import org.sourcelab.kafka.webview.ui.repository.ClusterRepository; +import org.sourcelab.kafka.webview.ui.repository.MessageFormatRepository; +import org.sourcelab.kafka.webview.ui.repository.ProducerMessageRepository; +import org.sourcelab.kafka.webview.ui.repository.ProducerRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import javax.validation.Valid; +import java.sql.Timestamp; +import java.util.*; + +/** + * Controller for CRUD over Producer entities. + */ +@Controller +@RequestMapping("/configuration/producer") +public class ProducerConfigController extends BaseController +{ + @Autowired + private ClusterRepository clusterRepository; + + @Autowired + private MessageFormatRepository messageFormatRepository; + + @Autowired + private ProducerRepository producerRepository; + + @Autowired + private ProducerMessageRepository producerMessageRepository; + + @Autowired + private KafkaOperationsFactory kafkaOperationsFactory; + + + @GetMapping + public String index(final Model model) { + // Setup breadcrumbs + setupBreadCrumbs(model, null, null); + + // Retrieve all message formats + final Iterable producerList = producerRepository.findAllByOrderByNameAsc(); + model.addAttribute("producers", producerList); + + return "configuration/producer/index"; + } + + /** + * GET Displays create producer form. + */ + @GetMapping( "/create") + public String createProducerForm( final ProducerForm producerForm, final Model model) + { + // Setup breadcrubs + if(!model.containsAttribute( "BreadCrumbs" )) + { + setupBreadCrumbs( model, "Create", null ); + } + + // Retrieve all clusters + model.addAttribute("clusters", clusterRepository.findAllByOrderByNameAsc()); + + // Retrieve all message formats + model.addAttribute("defaultMessageFormats", messageFormatRepository.findByIsDefaultOrderByNameAsc(true)); + model.addAttribute("customMessageFormats", messageFormatRepository.findByIsDefaultOrderByNameAsc(false)); + + model.addAttribute("topics", new ArrayList<>()); + + if( producerForm.getClusterId() != null ) + { + clusterRepository.findById( producerForm.getClusterId() ).ifPresent( (cluster) -> { + try (final KafkaOperations operations = kafkaOperationsFactory.create(cluster, getLoggedInUserId())) { + final TopicList topics = operations.getAvailableTopics(); + model.addAttribute("topics", topics.getTopics()); + + // If we have a selected topic + if (producerForm.getTopic() != null && !"!".equals(producerForm.getTopic())) { + final TopicDetails topicDetails = operations.getTopicDetails(producerForm.getTopic()); + model.addAttribute("partitions", topicDetails.getPartitions()); + } + } + } ); + } + + return "configuration/producer/create"; + } + + @RequestMapping(path = "/delete/{id}", method = RequestMethod.POST) + public String deleteProducer(@PathVariable final Long id, final RedirectAttributes redirectAttributes) { + // Retrieve it + if (!producerRepository.existsById(id)) { + // Set flash message & redirect + redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newWarning("Unable to find producer!")); + } else { + // Delete it + producerRepository.deleteById(id); + + redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newSuccess("Deleted producer!")); + } + + // redirect to cluster index + return "redirect:/configuration/producer"; + } + + @RequestMapping(path = "/update", method = RequestMethod.POST) + public String updateProducer( + @Valid final ProducerForm producerForm, + final BindingResult bindingResult, + final RedirectAttributes redirectAttributes, + final Model model) + { + // Determine if we're updating or creating + final boolean updateExisting = producerForm.exists(); + + // Ensure that producer name is not already used. + final Producer existingProducer = producerRepository.findByName(producerForm.getName()); + if (existingProducer != null) { + // If we're updating, exclude our own id. + if (!updateExisting + || !producerForm.getId().equals(existingProducer.getId())) { + bindingResult.addError(new FieldError( + "producerForm", "name", producerForm.getName(), true, null, null, "Name is already used") + ); + } + } + + // If we have errors + if (bindingResult.hasErrors()) { + return createProducerForm(producerForm, model); + } + + // If we're updating + final Producer producer; + final ProducerMessage producerMessage; + final String successMessage; + if (updateExisting) { + // Retrieve producer + final Optional producerOptional = producerRepository.findById(producerForm.getId()); + if (!producerOptional.isPresent()) { + // Set flash message and redirect + redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newWarning("Unable to find producer!")); + + // redirect to producer index + return "redirect:/configuration/producer"; + } + producer = producerOptional.get(); + + //Retrieve producer message + final Optional producerMessageOptional = producerMessageRepository.findByProducer( producer ); + if(!producerMessageOptional.isPresent()) + { + // Set flash message and redirect + redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newWarning("Unable to find producer's message!")); + + //redirect to producer index + return "redirect:/configuration/producer"; + } + producerMessage = producerMessageOptional.get(); + + successMessage = "Updated producer successfully!"; + } else { + producer = new Producer(); + producerMessage = new ProducerMessage(); + producer.setCreatedAt(new Timestamp(System.currentTimeMillis())); + producerMessage.setCreatedAt( new Timestamp( System.currentTimeMillis() ) ); + successMessage = "Created new producer!"; + } + + // Update properties +// TODO uncomment when we want to send more than a map of string/string as a kafka message +// final MessageFormat keyMessageFormat = messageFormatRepository.findById(producerForm.getKeyMessageFormatId()).get(); +// final MessageFormat valueMessageFormat = messageFormatRepository.findById(producerForm.getValueMessageFormatId()).get(); + final Cluster cluster = clusterRepository.findById(producerForm.getClusterId()).get(); + + producer.setName( producerForm.getName() ); + producer.setTopic( producerForm.getTopic() ); +// TODO uncomment when we want to send more than a map of string/string as a kafka message +// producer.setKeyMessageFormat( keyMessageFormat ); +// producer.setValueMessageFormat( valueMessageFormat ); + producer.setCluster( cluster ); + + producerMessage.setName( producer.getName() + "Message" ); + producerMessage.setQualifiedClassName( producerForm.getProducerMessageClassName() ); + producerMessage.setProducer( producer ); + producerMessage.setPropertyNameList( producerForm.getProducerMessagePropertyNameList() ); + + // Persist the producer + producer.setUpdatedAt(new Timestamp(System.currentTimeMillis())); + producerRepository.save(producer); + + // Persist the producer's message + producerMessage.setUpdatedAt( new Timestamp( System.currentTimeMillis() ) ); + producerMessageRepository.save( producerMessage ); + + + // Set flash message + redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newSuccess(successMessage)); + + // redirect to cluster index + return "redirect:/configuration/producer"; + + } + + private void setupBreadCrumbs(final Model model, String name, String url) { + // Setup breadcrumbs + final BreadCrumbManager manager = new BreadCrumbManager(model) + .addCrumb("Configuration", "/configuration"); + + if (name != null) { + manager.addCrumb("Producers", "/configuration/producer"); + manager.addCrumb(name, url); + } else { + manager.addCrumb("Producers", null); + } + } + +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/forms/ProducerForm.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/forms/ProducerForm.java new file mode 100644 index 00000000..f16e3be1 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/producer/forms/ProducerForm.java @@ -0,0 +1,155 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.controller.configuration.producer.forms; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +public class ProducerForm +{ + private Long id = null; + + @NotNull( message = "Enter a unique name") + @Size( min = 2, max = 255) + private String name; + + @NotNull(message = "Select a cluster") + private Long clusterId; +// TODO uncomment when we want to send more than a map of string/string as a kafka message +// @NotNull(message = "Select a message format") +// private Long keyMessageFormatId; +// +// @NotNull(message = "Select a message format") +// private Long valueMessageFormatId; + + @NotNull(message = "Select a topic") + @Size(min = 1, max = 255) + private String topic; + + @NotNull(message = "A producer must have property names to send a message") + private String producerMessagePropertyNameList; + + @NotNull(message = "A producer message must reference an existing class in the platform") + private String producerMessageClassName; + + public Long getId() + { + return id; + } + + public void setId( Long id ) + { + this.id = id; + } + + public String getName() + { + return name; + } + + public void setName( String name ) + { + this.name = name; + } + + public Long getClusterId() + { + return clusterId; + } + + public void setClusterId( Long clusterId ) + { + this.clusterId = clusterId; + } +// TODO uncomment when we want to send more than a map of string/string as a kafka message + +// public Long getKeyMessageFormatId() +// { +// return keyMessageFormatId; +// } +// +// public void setKeyMessageFormatId( Long keyMessageFormatId ) +// { +// this.keyMessageFormatId = keyMessageFormatId; +// } +// +// public Long getValueMessageFormatId() +// { +// return valueMessageFormatId; +// } +// +// public void setValueMessageFormatId( Long valueMessageFormatId ) +// { +// this.valueMessageFormatId = valueMessageFormatId; +// } + + public String getTopic() + { + return topic; + } + + public void setTopic( String topic ) + { + this.topic = topic; + } + + public String getProducerMessagePropertyNameList() + { + return producerMessagePropertyNameList; + } + + public void setProducerMessagePropertyNameList( String producerMessagePropertyNameList ) + { + this.producerMessagePropertyNameList = producerMessagePropertyNameList; + } + + public String getProducerMessageClassName() + { + return producerMessageClassName; + } + + public void setProducerMessageClassName( String producerMessageClassName ) + { + this.producerMessageClassName = producerMessageClassName; + } + + public boolean exists() { + return getId() != null; + } + + @Override + public String toString() + { + return "ProductForm{id=" + id + + ",name='" + name + '\'' + + ",clusterId=" + clusterId + +// TODO uncomment when we want to send more than a map of string/string as a kafka message +// ",keyMessageFormatId=" + keyMessageFormatId + +// ",valueMessageFormatId=" + valueMessageFormatId + + ",topic='" + topic + '\'' + + ",producerMessageKeys='" + producerMessagePropertyNameList + '\'' + + ",producerMessageClassName='" + producerMessageClassName + '\'' + + '}'; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/view/ViewConfigController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/view/ViewConfigController.java index 384fd0c7..27061b02 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/view/ViewConfigController.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/configuration/view/ViewConfigController.java @@ -123,8 +123,8 @@ public String createViewForm(final ViewForm viewForm, final Model model) { model.addAttribute("clusters", clusterRepository.findAllByOrderByNameAsc()); // Retrieve all message formats - model.addAttribute("defaultMessageFormats", messageFormatRepository.findByIsDefaultFormatOrderByNameAsc(true)); - model.addAttribute("customMessageFormats", messageFormatRepository.findByIsDefaultFormatOrderByNameAsc(false)); + model.addAttribute("defaultMessageFormats", messageFormatRepository.findByIsDefaultOrderByNameAsc(true)); + model.addAttribute("customMessageFormats", messageFormatRepository.findByIsDefaultOrderByNameAsc(false)); // If we have a cluster Id model.addAttribute("topics", new ArrayList<>()); diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/producer/ProducerController.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/producer/ProducerController.java new file mode 100644 index 00000000..aea898d4 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/controller/producer/ProducerController.java @@ -0,0 +1,149 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.controller.producer; + +import org.sourcelab.kafka.webview.ui.manager.ui.BreadCrumbManager; +import org.sourcelab.kafka.webview.ui.manager.ui.FlashMessage; +import org.sourcelab.kafka.webview.ui.model.Cluster; +import org.sourcelab.kafka.webview.ui.model.Producer; +import org.sourcelab.kafka.webview.ui.model.ProducerMessage; +import org.sourcelab.kafka.webview.ui.repository.ClusterRepository; +import org.sourcelab.kafka.webview.ui.repository.ProducerMessageRepository; +import org.sourcelab.kafka.webview.ui.repository.ProducerRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Controller +@RequestMapping("/producer") +public class ProducerController +{ + @Autowired + private ProducerRepository producerRepository; + + @Autowired + private ClusterRepository clusterRepository; + + @Autowired + private ProducerMessageRepository producerMessageRepository; + + /** + * GET producers index. + */ + @GetMapping + public String index( + final Model model, + @RequestParam( name = "clusterId", required = false) final Long clusterId + ) { + // Setup breadcrumbs + final BreadCrumbManager breadCrumbManager = new BreadCrumbManager(model); + + // Retrieve all clusters and index by id + final Map clustersById = new HashMap<>(); + clusterRepository + .findAllByOrderByNameAsc() + .forEach((cluster) -> clustersById.put(cluster.getId(), cluster)); + + final Iterable producers; + if (clusterId == null) { + // Retrieve all views order by name asc. + producers = producerRepository.findAllByOrderByNameAsc(); + } else { + // Retrieve only views for the cluster + producers = producerRepository.findAllByClusterIdOrderByNameAsc(clusterId); + } + + // Set model Attributes + model.addAttribute("producerList", producers); + model.addAttribute("clustersById", clustersById); + + final String clusterName; + if (clusterId != null && clustersById.containsKey(clusterId)) { + // If filtered by a cluster + clusterName = clustersById.get(clusterId).getName(); + + // Add top level breadcrumb + breadCrumbManager + .addCrumb("Producer", "/producer") + .addCrumb("Cluster: " + clusterName); + } else { + // If showing all views + clusterName = null; + + // Add top level breadcrumb + breadCrumbManager.addCrumb("Producer", null); + } + model.addAttribute("clusterName", clusterName); + + return "producer/index"; + } + + + /** + * GET Displays producer for specified id. + */ + @GetMapping( path = "/{id}") + public String produce( + @PathVariable final Long id, + final RedirectAttributes redirectAttributes, + final Model model) { + + // Retrieve the producer + final Optional producerOptional = producerRepository.findById(id); + if (!producerOptional.isPresent()) { + // Set flash message + redirectAttributes.addFlashAttribute("FlashMessage", FlashMessage.newWarning("Unable to find producer!")); + + // redirect to home + return "redirect:/"; + } + final Producer producer = producerOptional.get(); + + final Optional producerMessageOptional = producerMessageRepository.findByProducer( producer ); + if(!producerMessageOptional.isPresent()) + { + // yeah, I don't know. This shouldn't be possible + return "redirect:/"; + } + final ProducerMessage producerMessage = producerMessageOptional.get(); + + // Setup breadcrumbs + new BreadCrumbManager(model) + .addCrumb("Producer", "/producer") + .addCrumb(producer.getName()); + + // Set model Attributes + model.addAttribute("producer", producer); + model.addAttribute("cluster", producer.getCluster()); + model.addAttribute( "producerMessage", producerMessage ); + + return "producer/produce"; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/EntityUsageManager.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/EntityUsageManager.java new file mode 100644 index 00000000..29e52a00 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/EntityUsageManager.java @@ -0,0 +1,153 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.controller; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Interface for reporting dependencies. + */ +public interface EntityUsageManager { + + /** + * Given an instance Id, return a collection of any places that instance is used as a child dependency. + * @param id the instance id. + * @return Collection of usages. + */ + Collection findUsages(final long id); + + /** + * Defines a parent entity type and the instances of it that use the child relationship. + */ + class Usage { + private final String entityType; + private final Collection instances; + + /** + * Private Constructor -- Use Builder interface. + * @param entityType type of entity. + * @param usages all of the places its used. + */ + private Usage(final String entityType, final Collection usages) { + this.entityType = entityType; + this.instances = usages; + } + + public String getEntityType() { + return entityType; + } + + public Collection getInstances() { + return instances; + } + + @Override + public String toString() { + return getEntityType() + ": " + getInstances().toString(); + } + + /** + * Create new builder instance. + * @return new builder instance. + */ + public static UsageBuilder newBuilder() { + return new UsageBuilder(); + } + } + + /** + * Defines a usage. + */ + class UsageInstance { + private final String name; + private final long id; + + /** + * Private constructor. + * @param name Name of the instance. + * @param id of the instance. + */ + private UsageInstance(final String name, final long id) { + this.name = name; + this.id = id; + } + + public String getName() { + return name; + } + + public long getId() { + return id; + } + + @Override + public String toString() { + return name; + } + } + + /** + * For constructing usages. + */ + class UsageBuilder { + private final Map> instances = new HashMap<>(); + + /** + * Add a new instance to the builder. + * @param entityType Name of the entity, example: "View", or "Cluster" + * @param entityName name of the instance. example: view.name field. + * @param entityId Id of the instance. Example view.id field. + * @return builder for chaining. + */ + public UsageBuilder withInstance(final String entityType, final String entityName, final long entityId) { + if (!instances.containsKey(entityType)) { + instances.put(entityType, new ArrayList<>()); + } + instances.get(entityType).add(new UsageInstance(entityName, entityId)); + return this; + } + + /** + * Build instance. + * @return collection of usages. + */ + public Collection build() { + if (instances.isEmpty()) { + return Collections.emptyList(); + } + + final List usages = new ArrayList<>(); + for (final Map.Entry> entry : instances.entrySet()) { + usages.add(new Usage(entry.getKey(), entry.getValue())); + } + return Collections.unmodifiableCollection(usages); + } + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarControllerHelper.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarControllerHelper.java new file mode 100644 index 00000000..567085cc --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarControllerHelper.java @@ -0,0 +1,416 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.controller; + + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.sourcelab.kafka.webview.ui.manager.plugin.PluginFactory; +import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; +import org.sourcelab.kafka.webview.ui.manager.plugin.exception.LoaderException; +import org.sourcelab.kafka.webview.ui.manager.ui.BreadCrumbManager; +import org.sourcelab.kafka.webview.ui.manager.ui.FlashMessage; +import org.sourcelab.kafka.webview.ui.model.UploadableJarEntity; +import org.sourcelab.kafka.webview.ui.repository.UploadableJarRepository; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; + +/** + * Abstracted out common controller code for UploadableJarEntity instances. + * + * @param The entity the controller is performing operations on. + */ +public class UploadableJarControllerHelper { + + /** + * Dependencies. + */ + private final UploadManager uploadManager; + private final PluginFactory pluginFactory; + private final UploadableJarRepository entityRepository; + + /** + * Configuration. + */ + private final String moduleName; + private final String entityDisplayNameSingular; + private final String entityDisplayNamePlural; + private final Class entityClass; + + /** + * Constructor. + * @param entityDisplayNameSingular Display name of entity, in singular form. Example "Message Format" + * @param entityDisplayNamePlural Display name of the entity, in plural form. Example "Message Formats" + * @param moduleName path of the module in the UI. example: "messageFormat" + * @param entityClass Class of the entity. + * @param uploadManager UploadManager instance. + * @param pluginFactory PluginFactory instance. + * @param entityRepository EntityT's repository instance. + */ + public UploadableJarControllerHelper( + final String entityDisplayNameSingular, + final String entityDisplayNamePlural, + final String moduleName, + final Class entityClass, + final UploadManager uploadManager, + final PluginFactory pluginFactory, + final UploadableJarRepository entityRepository) { + + this.uploadManager = uploadManager; + this.pluginFactory = pluginFactory; + this.entityRepository = entityRepository; + this.moduleName = moduleName; + this.entityDisplayNameSingular = entityDisplayNameSingular; + this.entityDisplayNamePlural = entityDisplayNamePlural; + this.entityClass = entityClass; + } + + /** + * Build the controllers index response. + * @param model controller's model instance. + * @return Controller response. + */ + public String buildIndex(final Model model) { + // Setup breadcrumbs + setupBreadCrumbs(model, "Create", null); + + // Retrieve all default formats + final Iterable defaultEntries = entityRepository.findByIsDefaultOrderByNameAsc(true); + + // Retrieve all custom formats + final Iterable customEntries = entityRepository.findByIsDefaultOrderByNameAsc(false); + + // Set view attributes + model.addAttribute("defaultEntries", defaultEntries); + model.addAttribute("customEntries", customEntries); + + return this.moduleName + "/index"; + } + + /** + * Build the controllers create response. + * @param model controller's model instance. + * @return Controller response. + */ + public String buildCreate(final Model model) { + // Setup breadcrumbs + setupBreadCrumbs(model, "Create", null); + + return this.moduleName + "/create"; + } + + /** + * Build the controllers edit response. + * @param id id of entity + * @param form Controller's form instance. + * @param model Controller's model instance. + * @param redirectAttributes Controller's redirectAttributes instance. + * @return Controller response. + */ + public String buildEdit( + final long id, + final UploadableJarForm form, + final Model model, + final RedirectAttributes redirectAttributes + ) { + + // Retrieve it + final Optional entityOptional = entityRepository.findById(id); + if (!entityOptional.isPresent()) { + // Set flash message & redirect + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newWarning("Unable to find " + this.entityDisplayNameSingular.toLowerCase() + "!") + ); + return "redirect:/" + this.moduleName; + } + final EntityT entity = entityOptional.get(); + + // Setup breadcrumbs + setupBreadCrumbs(model, "Edit " + entity.getName(), null); + + // Setup form + form.setId(entity.getId()); + form.setName(entity.getName()); + form.setClasspath(entity.getClasspath()); + + // Deserialize message parameters json string into a map + final ObjectMapper objectMapper = new ObjectMapper(); + Map customOptions; + try { + customOptions = objectMapper.readValue(entity.getOptionParameters(), Map.class); + } catch (final IOException e) { + // Fail safe? + customOptions = new HashMap<>(); + } + + // Update form object with properties. + for (final Map.Entry entry : customOptions.entrySet()) { + form.getCustomOptionNames().add(entry.getKey()); + form.getCustomOptionValues().add(entry.getValue()); + } + + return this.moduleName + "/create"; + } + + /** + * Handle processing controller's update response. + * @param form Controller's form instance. + * @param bindingResult Controller's binding result instance. + * @param redirectAttributes Controller's redirectAttributes instance. + * @return Controller response. + */ + public String handleUpdate( + final UploadableJarForm form, + final BindingResult bindingResult, + final RedirectAttributes redirectAttributes + ) { + + // If we have errors just display the form again. + if (bindingResult.hasErrors()) { + return this.moduleName + "/create"; + } + + // Grab uploaded file + final MultipartFile file = form.getFile(); + + // If the partitioning strategy doesn't exist, and no file uploaded. + if (!form.exists() && file.isEmpty()) { + bindingResult.addError(new FieldError( + bindingResult.getObjectName(), "file", "", true, null, null, "Select a jar to upload") + ); + return this.moduleName + "/create"; + } + + // If Partitioning Strategy exists + final EntityT entity; + if (form.exists()) { + // Retrieve partitioning strategy + final Optional entityOptional = entityRepository.findById(form.getId()); + + // If we can't find the entry + if (!entityOptional.isPresent()) { + // Set flash message & redirect + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newWarning("Unable to find " + entityDisplayNameSingular.toLowerCase() + "!") + ); + return "redirect:/" + this.moduleName; + } + entity = entityOptional.get(); + } else { + // Creating new partitioning strategy + try { + entity = entityClass.getDeclaredConstructor().newInstance(); + } catch (final InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new RuntimeException(e.getMessage(), e); + } + } + + // Handle custom options, convert into a JSON string. + final String jsonStr = handleCustomOptions(form); + + // If we have a new file uploaded. + if (!file.isEmpty()) { + try { + // Sanitize file name. + final String newFilename = form.getName().replaceAll("[^A-Za-z0-9]", "_") + ".jar"; + final String tempFilename = newFilename + ".tmp"; + + // Persist jar on filesystem in a temporary location + final String jarPath = uploadManager.handleUpload(file, tempFilename, entity.getUploadType()); + + // Attempt to load jar? + try { + pluginFactory.getPlugin(tempFilename, form.getClasspath()); + } catch (final LoaderException exception) { + // If we had issues, remove the temp location + Files.delete(Paths.get(jarPath)); + + // Add an error + bindingResult.addError(new FieldError( + bindingResult.getObjectName(), "file", "", true, null, null, exception.getMessage()) + ); + // And re-display the form. + return this.moduleName + "/create"; + } + // Ok new JAR looks good. + // 1 - remove pre-existing jar if it exists + if (entity.getJar() != null && !entity.getJar().isEmpty()) { + // Delete pre-existing jar. + Files.deleteIfExists(pluginFactory.getPathForJar(entity.getJar())); + } + + // 2 - move tempFilename => filename. + // Lets just delete the temp path and re-handle the upload. + Files.deleteIfExists(Paths.get(jarPath)); + uploadManager.handleUpload(file, newFilename, entity.getUploadType()); + + // 3 - Update the jar and class path properties. + entity.setJar(newFilename); + entity.setClasspath(form.getClasspath()); + } catch (final IOException e) { + // Set flash message + redirectAttributes.addFlashAttribute("exception", e.getMessage()); + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newWarning("Unable to save uploaded JAR: " + e.getMessage())); + + // redirect to cluster index + return "redirect:/" + this.moduleName; + } + } + + // If we made it here, write MessageFormat entity. + entity.setName(form.getName()); + entity.setDefault(false); + entity.setOptionParameters(jsonStr); + entityRepository.save(entity); + + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newSuccess("Successfully created " + this.entityDisplayNameSingular.toLowerCase() + "!")); + return "redirect:/" + this.moduleName; + } + + /** + * Process a delete request for a UploadableJarEntity controller. + * @param id of entity to process delete for. + * @param redirectAttributes Controllers redirect attributes. + * @param entityUsageManager Implementation for finding usages of this entity. + * @return controller response. + */ + public String processDelete( + final Long id, + final RedirectAttributes redirectAttributes, + final EntityUsageManager entityUsageManager + ) { + // Where to redirect. + final String redirectUrl = "redirect:/" + this.moduleName; + + // Retrieve it + final Optional entityOptional = entityRepository.findById(id); + if (!entityOptional.isPresent() || entityOptional.get().isDefault()) { + // Set flash message & redirect + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newWarning("Unable to remove " + this.entityDisplayNameSingular + "!")); + return redirectUrl; + } + final EntityT entity = entityOptional.get(); + + // See if its in use by anything. + final Collection usages = entityUsageManager.findUsages(id); + if (!usages.isEmpty()) { + // Build message + String errorMessage = this.entityDisplayNameSingular + " in use by "; + Collection errorMsgUsages = new HashSet<>(); + for (final EntityUsageManager.Usage usage : usages) { + errorMsgUsages.add(usage.toString()); + } + + // Set flash message & redirect + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newWarning(errorMessage + String.join(", ", errorMsgUsages))); + return redirectUrl; + } + + try { + // Delete entity + entityRepository.deleteById(id); + + // Delete jar from disk + try { + Files.deleteIfExists(pluginFactory.getPathForJar(entity.getJar())); + } catch (final NoSuchFileException exception) { + // swallow. + } + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newSuccess("Deleted " + this.entityDisplayNameSingular + "!")); + } catch (final IOException e) { + redirectAttributes.addFlashAttribute( + "FlashMessage", + FlashMessage.newWarning("Unable to remove " + this.entityDisplayNameSingular + "! " + e.getMessage())); + return redirectUrl; + } + + // redirect to cluster index + return redirectUrl; + } + + /** + * Handles getting custom defined options and values. + * @param form The submitted form. + */ + private String handleCustomOptions(final UploadableJarForm form) { + // Build a map of Name => Value + final Map mappedOptions = form.getCustomOptionsAsMap(); + + // For converting map to json string + final ObjectMapper objectMapper = new ObjectMapper(); + + try { + return objectMapper.writeValueAsString(mappedOptions); + } catch (final JsonProcessingException e) { + // Fail safe? + return "{}"; + } + } + + /** + * Sets up breadcrumbs in UI. + * @param model controller model instance. + * @param name name of entity, or null. + * @param url Optional URL to display. + */ + private void setupBreadCrumbs(final Model model, final String name, final String url) { + // Setup breadcrumbs + final BreadCrumbManager manager = new BreadCrumbManager(model) + .addCrumb("Configuration", "/configuration"); + + if (name != null) { + manager.addCrumb(entityDisplayNamePlural, "/" + this.moduleName); + manager.addCrumb(name, url); + } else { + manager.addCrumb(entityDisplayNamePlural, null); + } + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarForm.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarForm.java new file mode 100644 index 00000000..f24097cb --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/controller/UploadableJarForm.java @@ -0,0 +1,140 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.controller; + +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Abstracted common UploadableJarEntity form methods into re-usable abstract instance. + */ +public abstract class UploadableJarForm { + private Long id = null; + + @NotNull(message = "Enter a unique name") + @Size(min = 2, max = 255) + private String name; + + @NotNull(message = "Enter a classpath") + @Size(min = 2, max = 1024) + private String classpath; + + private MultipartFile file; + + /** + * Names of custom options. + */ + private List customOptionNames = new ArrayList<>(); + + /** + * Values of custom options. + */ + private List customOptionValues = new ArrayList<>(); + + public Long getId() { + return id; + } + + public void setId(final Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getClasspath() { + return classpath; + } + + public void setClasspath(final String classpath) { + this.classpath = classpath; + } + + public MultipartFile getFile() { + return file; + } + + public void setFile(final MultipartFile file) { + this.file = file; + } + + public List getCustomOptionNames() { + return customOptionNames; + } + + public void setCustomOptionNames(final List customOptionNames) { + this.customOptionNames = customOptionNames; + } + + public List getCustomOptionValues() { + return customOptionValues; + } + + public void setCustomOptionValues(final List customOptionValues) { + this.customOptionValues = customOptionValues; + } + + /** + * Utility method to return custom options as a map. + */ + public Map getCustomOptionsAsMap() { + // Build a map of Name => Value + final Map mappedOptions = new HashMap<>(); + + final Iterator names = getCustomOptionNames().iterator(); + final Iterator values = getCustomOptionValues().iterator(); + + while (names.hasNext()) { + final String name = names.next(); + final String value; + if (values.hasNext()) { + value = values.next(); + } else { + value = ""; + } + mappedOptions.put(name, value); + } + return mappedOptions; + } + + /** + * Does the MessageFormat that this form represents already exist in the database. + */ + public boolean exists() { + return getId() != null; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/KafkaProducerFactory.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/KafkaProducerFactory.java new file mode 100644 index 00000000..e829967d --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/KafkaProducerFactory.java @@ -0,0 +1,99 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.sourcelab.kafka.webview.ui.manager.kafka.KafkaClientConfigUtil; +import org.sourcelab.kafka.webview.ui.manager.kafka.producer.config.WebProducerConfig; + +import java.util.Map; + +/** + * Creates KafkaProducer instances. + */ +public class KafkaProducerFactory { + /** + * Utility class for setting up common kafka client properties. + */ + private final KafkaClientConfigUtil configUtil; + + /** + * Constructor. + * @param configUtil Utility class to DRY out common kafka client settings. + */ + public KafkaProducerFactory(final KafkaClientConfigUtil configUtil) { + if (configUtil == null) { + throw new RuntimeException("Missing dependency KafkaClientConfigUtil!"); + } + this.configUtil = configUtil; + } + + /** + * Factory method. + * @param producerConfig Configuration for the producer. + * @return new WebKafkaProducer instance. + */ + public WebKafkaProducer createWebProducer(final WebProducerConfig producerConfig) { + final Map producerProperties = buildProducerProperties(producerConfig); + final KafkaProducer kafkaProducer = createProducer(producerProperties); + + return new WebKafkaProducer(kafkaProducer, producerConfig); + } + + /** + * Create underlying KafkaProducer instance. + * @param producerProperties kafka producer configuration properties. + * @return new KafkaProducer instance. + */ + private KafkaProducer createProducer(final Map producerProperties) { + return new KafkaProducer(producerProperties); + } + + /** + * Build configuration for KafkaProducer. + * @param webProducerConfig Configuration set. + * @return Map of KafkaProducer options. + */ + private Map buildProducerProperties(final WebProducerConfig webProducerConfig) { + final Map producerProperties = configUtil.applyCommonSettings( + webProducerConfig.getClusterConfig(), + webProducerConfig.getProducerClientId() + ); + + // Default options + producerProperties.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 1); + producerProperties.put(ProducerConfig.RETRIES_CONFIG, 5); + producerProperties.put(ProducerConfig.BATCH_SIZE_CONFIG, 0); + + // Configurable options + producerProperties.put(ProducerConfig.ACKS_CONFIG, webProducerConfig.getAckRequirement()); + producerProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, webProducerConfig.getKeyTransformer().getSerializerClass()); + producerProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, webProducerConfig.getValueTransformer().getSerializerClass()); + producerProperties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, webProducerConfig.getPartitionerClass()); + + return producerProperties; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebKafkaProducer.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebKafkaProducer.java new file mode 100644 index 00000000..a69a5363 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebKafkaProducer.java @@ -0,0 +1,101 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sourcelab.kafka.webview.ui.manager.kafka.producer.config.WebProducerConfig; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Intended to be an abstraction around KafkaProducer for use in publishing from a web interface. + */ +public class WebKafkaProducer { + private static final Logger logger = LoggerFactory.getLogger(WebKafkaProducer.class); + + /** + * The abstracted KafkaProducer instance for talking to kafka. + */ + private final KafkaProducer kafkaProducer; + + /** + * Configuration defining properties about how/what/where to producer. + */ + private final WebProducerConfig producerConfig; + + /** + * Constructor. + * @param kafkaProducer The underlying/wrapped KafkaProducer instance. + * @param producerConfig The client configuration. + */ + public WebKafkaProducer(final KafkaProducer kafkaProducer, final WebProducerConfig producerConfig) { + this.kafkaProducer = kafkaProducer; + this.producerConfig = producerConfig; + } + + /** + * Producer a record to Kafka. + * @param webRecord the record to publish. + */ + public void produce(final WebProducerRecord webRecord) { + // Convert Key to appropriate value. + final Object key = producerConfig.getKeyTransformer().transform( + producerConfig.getTopic(), + webRecord.getKeyValues() + ); + + // Convert Value to appropriate value. + final Object value = producerConfig.getValueTransformer().transform( + producerConfig.getTopic(), + webRecord.getValueValues() + ); + + // Create producer record. + final ProducerRecord record = new ProducerRecord( + producerConfig.getTopic(), + key, + value + ); + + // publish + final Future future = kafkaProducer.send(record); + + // Wait for it to complete. + try { + future.get( + producerConfig.getTimeoutMs(), TimeUnit.MILLISECONDS + ); + } catch (final InterruptedException | ExecutionException | TimeoutException exception) { + logger.error("Error publishing record: " + exception.getMessage(), exception); + throw new RuntimeException(exception.getMessage(), exception); + } + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebProducerRecord.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebProducerRecord.java new file mode 100644 index 00000000..aa2d8d0d --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/WebProducerRecord.java @@ -0,0 +1,62 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents records to be published via WebKafkaProducer. + */ +public class WebProducerRecord { + /** + * Map of fieldName to values for key. + */ + private final Map keyValues; + + /** + * Map of fieldName to values for value. + */ + private final Map valueValues; + + /** + * Constructor. + * @param keyValues Map of fieldName to values for key. + * @param valueValues Map of fieldName to values for value. + */ + public WebProducerRecord(final Map keyValues, final Map valueValues) { + this.keyValues = Collections.unmodifiableMap(new HashMap<>(keyValues)); + this.valueValues = Collections.unmodifiableMap(new HashMap<>(valueValues)); + } + + public Map getKeyValues() { + return keyValues; + } + + public Map getValueValues() { + return valueValues; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/config/WebProducerConfig.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/config/WebProducerConfig.java new file mode 100644 index 00000000..06de964f --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/config/WebProducerConfig.java @@ -0,0 +1,189 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer.config; + +import org.apache.kafka.clients.producer.Partitioner; +import org.apache.kafka.clients.producer.internals.DefaultPartitioner; +import org.sourcelab.kafka.webview.ui.manager.kafka.config.ClusterConfig; +import org.sourcelab.kafka.webview.ui.manager.kafka.producer.transformer.ValueTransformer; + +import java.util.concurrent.TimeUnit; + +/** + * Defines configuration values for producing into a kafka topic. + */ +public class WebProducerConfig { + private final ClusterConfig clusterConfig; + private final String topic; + private final String producerClientId; + + // Serialization options + private final ValueTransformer keyTransformer; + private final ValueTransformer valueTransformer; + + // Partitioning Options + private final Class partitionerClass; + + /** + * Producer timeout, in millis seconds. Defaults 15 secs. + * TODO allow configuring. + */ + private final long timeoutMs = TimeUnit.SECONDS.toMillis(15); + + /** + * What level of acknowledgement is required during the publish. + * TODO Allow configuring. + */ + private final String ackRequirement = "1"; + + /** + * Constructor. + * @param clusterConfig defines what cluster to connect to. + * @param topic defines which topic to produce to. + * @param producerClientId identifier for who is publishing. + * @param keyTransformer how to serialize keys. + * @param valueTransformer how to serialize values. + * @param partitionerClass how to partition records published to kafka. + */ + public WebProducerConfig( + final ClusterConfig clusterConfig, + final String topic, + final String producerClientId, + final ValueTransformer keyTransformer, + final ValueTransformer valueTransformer, + final Class partitionerClass + ) { + if (clusterConfig == null) { + throw new IllegalArgumentException("Cluster Config may not be null"); + } + if (topic == null) { + throw new IllegalArgumentException("topic may not be null"); + } + if (producerClientId == null) { + throw new IllegalArgumentException("clientId may not be null"); + } + if (keyTransformer == null) { + throw new IllegalArgumentException("key transformer may not be null"); + } + if (valueTransformer == null) { + throw new IllegalArgumentException("value transformer may not be null"); + } + if (partitionerClass == null) { + throw new IllegalArgumentException("partitioner may not be null"); + } + + this.clusterConfig = clusterConfig; + this.topic = topic; + this.producerClientId = producerClientId; + this.keyTransformer = keyTransformer; + this.valueTransformer = valueTransformer; + this.partitionerClass = partitionerClass; + } + + public ClusterConfig getClusterConfig() { + return clusterConfig; + } + + public String getTopic() { + return topic; + } + + public String getProducerClientId() { + return producerClientId; + } + + public ValueTransformer getKeyTransformer() { + return keyTransformer; + } + + public ValueTransformer getValueTransformer() { + return valueTransformer; + } + + public long getTimeoutMs() { + return timeoutMs; + } + + public String getAckRequirement() { + return ackRequirement; + } + + public Class getPartitionerClass() { + return partitionerClass; + } + + public static Builder newBuilder() { + return new Builder(); + } + + /** + * Builder instance for WebProducerConfig. + */ + public static final class Builder { + private ClusterConfig clusterConfig; + private String topic; + private String producerClientId; + private ValueTransformer keyTransformer; + private ValueTransformer valueTransformer; + private Class partitionerClass = DefaultPartitioner.class; + + private Builder() { + } + + public Builder withClusterConfig(ClusterConfig clusterConfig) { + this.clusterConfig = clusterConfig; + return this; + } + + public Builder withTopic(String topic) { + this.topic = topic; + return this; + } + + public Builder withProducerClientId(String producerClientId) { + this.producerClientId = producerClientId; + return this; + } + + public Builder withKeyTransformer(ValueTransformer keyTransformer) { + this.keyTransformer = keyTransformer; + return this; + } + + public Builder withValueTransformer(ValueTransformer valueTransformer) { + this.valueTransformer = valueTransformer; + return this; + } + + public Builder withPartitionerClass(Class partitionerClass) { + this.partitionerClass = partitionerClass; + return this; + } + + public WebProducerConfig build() { + return new WebProducerConfig(clusterConfig, topic, producerClientId, keyTransformer, valueTransformer, partitionerClass); + } + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/DefaultTransformer.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/DefaultTransformer.java new file mode 100644 index 00000000..42ef3b4d --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/DefaultTransformer.java @@ -0,0 +1,87 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer.transformer; + +import javax.validation.constraints.NotNull; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * Abstract implementation for default serializers bundled with Kafka client package. + * @param value that the serializer instance is expecting. + */ +public abstract class DefaultTransformer implements ValueTransformer { + + private static final String defaultFieldName = "value"; + private static final Collection defaultFieldNames = Collections.singletonList(defaultFieldName); + + /** + * Configure this class. + * @param configs configs in key/value pairs + * @param isKey whether is for key or value + */ + public void configure(final Map configs, boolean isKey) { + // No-op. + } + + /** + * Transformation logic. + * @param topic Name of topic being published to. + * @param valueMap map of field names to the values entered by the user. + * @return T object of instance type expected by the serializer. + */ + public T transform(final String topic, final Map valueMap) { + final String value = valueMap.getOrDefault(defaultFieldName, null); + if (value == null) { + return null; + } + return transformField(valueMap.get(defaultFieldName)); + } + + /** + * Implement to transform into the single value. + * @param value value to be transformed from a String into type T + * @return T object of instance type expected by the serializer. + */ + public abstract T transformField(@NotNull String value); + + /** + * Return collection of field names to collect values for. + * @return Collection of file names. + */ + public Collection getFieldNames() { + return defaultFieldNames; + } + + /** + * Utility method for constructing value maps for Default transformers. + * @param value String value + * @return Value map. + */ + public static Map createDefaultValueMap(final String value) { + return Collections.singletonMap(defaultFieldName, value); + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/LongTransformer.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/LongTransformer.java new file mode 100644 index 00000000..a982107d --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/LongTransformer.java @@ -0,0 +1,49 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer.transformer; + +import org.apache.kafka.common.serialization.LongSerializer; +import org.apache.kafka.common.serialization.Serializer; + +import javax.validation.constraints.NotNull; + +/** + * For transforming single string input to a Long for the LongSerializer. + */ +public class LongTransformer extends DefaultTransformer { + @Override + public Class getSerializerClass() { + return LongSerializer.class; + } + + @Override + public Long transformField(@NotNull final String value) { + try { + return Long.valueOf(value); + } catch (final NumberFormatException e) { + return null; + } + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/StringTransformer.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/StringTransformer.java new file mode 100644 index 00000000..b1480ee7 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/StringTransformer.java @@ -0,0 +1,46 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer.transformer; + +import org.apache.kafka.common.serialization.Serializer; +import org.apache.kafka.common.serialization.StringSerializer; + +import javax.validation.constraints.NotNull; + +/** + * For transforming single string input to a Long for the StringSerializer. + */ +public class StringTransformer extends DefaultTransformer { + + @Override + public String transformField(@NotNull final String value) { + return value; + } + + @Override + public Class getSerializerClass() { + return StringSerializer.class; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/ValueTransformer.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/ValueTransformer.java new file mode 100644 index 00000000..e02c96c9 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/kafka/producer/transformer/ValueTransformer.java @@ -0,0 +1,65 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.manager.kafka.producer.transformer; + +import org.apache.kafka.common.serialization.Serializer; + +import java.util.Collection; +import java.util.Map; + +/** + * For mapping web entered data to a serializer instance. + * + * @param value that the serializer instance is expecting. + */ +public interface ValueTransformer { + + /** + * Configure this class. + * @param configs configs in key/value pairs + * @param isKey whether is for key or value + */ + void configure(final Map configs, boolean isKey); + + /** + * Transformation logic. + * @param topic The topic being produced to. + * @param valueMap Map of values to produce. + * @return Serialized/flattened value that will get passed to the serializer instance. + */ + T transform(final String topic, final Map valueMap); + + /** + * Underlying Kafka value serializer class. + * @return Underlying Kafka value serializer class. + */ + Class getSerializerClass(); + + /** + * Return collection of field names to collect values for. + * @return Collection of file names. + */ + Collection getFieldNames(); +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/plugin/UploadManager.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/plugin/UploadManager.java index 789e53ff..ef1b6431 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/plugin/UploadManager.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/manager/plugin/UploadManager.java @@ -56,6 +56,11 @@ public class UploadManager { */ private final String keyStoreUploadPath; + /** + * Where to upload JARs associated with partitioning strategies. + */ + private final String partitioningStrategyUploadPath; + /** * Constructor. * @param uploadPath Parent upload directory. @@ -64,12 +69,17 @@ public UploadManager(final String uploadPath) { this.deserializerUploadPath = uploadPath + "/deserializers"; this.filterUploadPath = uploadPath + "/filters"; this.keyStoreUploadPath = uploadPath + "/keyStores"; + this.partitioningStrategyUploadPath = uploadPath + "/partitioners"; } String getDeserializerUploadPath() { return deserializerUploadPath; } + String getPartitioningStrategyUploadPath() { + return partitioningStrategyUploadPath; + } + String getFilterUploadPath() { return filterUploadPath; } @@ -88,6 +98,16 @@ public String handleDeserializerUpload(final MultipartFile file, final String ou return handleFileUpload(file, outFileName, getDeserializerUploadPath()); } + /** + * Handle uploading a Deserializer Jar. + * @param file The Uploaded MultiPart file. + * @param outFileName What we want to name the output file. + * @return Path to uploaded file. + */ + public String handlePartitioningStrategyUpload(final MultipartFile file, final String outFileName) throws IOException { + return handleFileUpload(file, outFileName, getPartitioningStrategyUploadPath()); + } + /** * Handle uploading a Filter Jar. * @param file The Uploaded MultiPart file. @@ -108,6 +128,29 @@ public String handleKeystoreUpload(final MultipartFile file, final String outFil return handleFileUpload(file, outFileName, getKeyStoreUploadPath()); } + /** + * Handle upload for a given upload type. + * @param file The Uploaded MultiPart file. + * @param outFileName What we want to name the output file. + * @param uploadType the type of upload. + * @return Path to uploaded file. + */ + public String handleUpload(final MultipartFile file, final String outFileName, final UploadType uploadType) throws IOException { + switch (uploadType) { + case DESERIALIZER: + return handleDeserializerUpload(file, outFileName); + case FILTER: + return handleFilterUpload(file, outFileName); + case KEYSTORE: + return handleKeystoreUpload(file, outFileName); + case PARTITIONING_STRATEGY: + return handlePartitioningStrategyUpload(file, outFileName); + case SERIALIZER: + default: + throw new IllegalArgumentException("Unknown upload type: " + uploadType); + } + } + /** * Enables the ability to delete a keystore file. * @param keyStoreFile Filename of keystore file to be removed. @@ -117,6 +160,12 @@ public boolean deleteKeyStore(final String keyStoreFile) { return deleteFile(keyStoreFile, keyStoreUploadPath); } + /** + * Removes a file if it exists. + * @param filename filename to remove + * @param rootPath the directory in which the file should exist. + * @return true if removed, false on errors. + */ private boolean deleteFile(final String filename, final String rootPath) { // Handle nulls gracefully. if (filename == null || filename.trim().isEmpty()) { @@ -163,4 +212,15 @@ private String handleFileUpload(final MultipartFile file, final String outFileNa return fullOutputPath.toString(); } + + /** + * Enum describing the different upload types. + */ + public enum UploadType { + DESERIALIZER, + FILTER, + KEYSTORE, + PARTITIONING_STRATEGY, + SERIALIZER; + } } diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/MessageFormat.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/MessageFormat.java index 0e710e1e..80132820 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/MessageFormat.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/MessageFormat.java @@ -24,17 +24,20 @@ package org.sourcelab.kafka.webview.ui.model; +import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; + import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; +import javax.persistence.Transient; /** * Represents a record in the message_format table. */ @Entity -public class MessageFormat { +public class MessageFormat implements UploadableJarEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @@ -86,6 +89,18 @@ public void setJar(final String jar) { this.jar = jar; } + @Transient + @Override + public boolean isDefault() { + return isDefaultFormat(); + } + + @Transient + @Override + public void setDefault(final boolean defaultFormat) { + setDefaultFormat(defaultFormat); + } + public boolean isDefaultFormat() { return isDefaultFormat; } @@ -102,6 +117,11 @@ public void setOptionParameters(final String optionParameters) { this.optionParameters = optionParameters; } + @Override + public UploadManager.UploadType getUploadType() { + return UploadManager.UploadType.DESERIALIZER; + } + @Override public String toString() { return "MessageFormat{" diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/PartitioningStrategy.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/PartitioningStrategy.java new file mode 100644 index 00000000..5448c6aa --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/PartitioningStrategy.java @@ -0,0 +1,123 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.model; + +import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +/** + * Represents a partitioning strategy. + */ +@Entity +public class PartitioningStrategy implements UploadableJarEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private String classpath; + + @Column(nullable = false, unique = true) + private String jar; + + @Column(nullable = false) + private boolean isDefault = false; + + @Column(nullable = false) + private String optionParameters = "{}"; + + public long getId() { + return id; + } + + public void setId(final long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getClasspath() { + return classpath; + } + + public void setClasspath(final String classpath) { + this.classpath = classpath; + } + + public String getJar() { + return jar; + } + + public void setJar(final String jar) { + this.jar = jar; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(final boolean isDefault) { + this.isDefault = isDefault; + } + + public String getOptionParameters() { + return optionParameters; + } + + public void setOptionParameters(final String optionParameters) { + this.optionParameters = optionParameters; + } + + @Override + public UploadManager.UploadType getUploadType() { + return UploadManager.UploadType.PARTITIONING_STRATEGY; + } + + @Override + public String toString() { + return "PartitioningStrategy{" + + "id=" + id + + ", name='" + name + '\'' + + ", classpath='" + classpath + '\'' + + ", jar='" + jar + '\'' + + ", isDefault=" + isDefault + + ", optionParameters='" + optionParameters + '\'' + + '}'; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/Producer.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/Producer.java new file mode 100644 index 00000000..397137c4 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/Producer.java @@ -0,0 +1,155 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.model; + +import org.hibernate.annotations.Cascade; + +import javax.persistence.*; +import java.sql.Timestamp; + +@Entity +public class Producer +{ + @Id + @GeneratedValue( strategy = GenerationType.IDENTITY) + private Long id; + + @Column( nullable = false, unique = true) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + private Cluster cluster; + + //TODO uncomment when we want to send more than a map of string/string as a kafka message +// @ManyToOne(fetch = FetchType.LAZY) +// private MessageFormat keyMessageFormat; +// +// @ManyToOne(fetch = FetchType.LAZY) +// private MessageFormat valueMessageFormat; + + @Column(nullable = false) + private String topic; + + @Column(nullable = false) + private Timestamp createdAt; + + @Column(nullable = false) + private Timestamp updatedAt; + + @JoinColumn(name = "id", referencedColumnName = "producer_id") + @OneToOne(fetch = FetchType.LAZY) + @Cascade( org.hibernate.annotations.CascadeType.DELETE ) + private ProducerMessage producerMessage; + + public Long getId() + { + return id; + } + + public void setId( Long id ) + { + this.id = id; + } + + public String getName() + { + return name; + } + + public void setName( String name ) + { + this.name = name; + } + + public Cluster getCluster() + { + return cluster; + } + + public void setCluster( Cluster cluster ) + { + this.cluster = cluster; + } +// TODO uncomment when we want to send more than a map of string/string as a kafka message + +// public MessageFormat getKeyMessageFormat() +// { +// return keyMessageFormat; +// } +// +// public void setKeyMessageFormat( MessageFormat keyMessageFormat ) +// { +// this.keyMessageFormat = keyMessageFormat; +// } +// +// public MessageFormat getValueMessageFormat() +// { +// return valueMessageFormat; +// } +// +// public void setValueMessageFormat( MessageFormat valueMessageFormat ) +// { +// this.valueMessageFormat = valueMessageFormat; +// } + + public String getTopic() + { + return topic; + } + + public void setTopic( String topic ) + { + this.topic = topic; + } + + public Timestamp getCreatedAt() + { + return createdAt; + } + + public void setCreatedAt( Timestamp createdAt ) + { + this.createdAt = createdAt; + } + + public Timestamp getUpdatedAt() + { + return updatedAt; + } + + public void setUpdatedAt( Timestamp updatedAt ) + { + this.updatedAt = updatedAt; + } + + public ProducerMessage getProducerMessage() + { + return producerMessage; + } + + public void setProducerMessage( ProducerMessage producerMessage ) + { + this.producerMessage = producerMessage; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/ProducerMessage.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/ProducerMessage.java new file mode 100644 index 00000000..511216f4 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/ProducerMessage.java @@ -0,0 +1,129 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.model; + +import javax.persistence.*; +import java.sql.Timestamp; + +@Entity +public class ProducerMessage +{ + @Id + @GeneratedValue( strategy = GenerationType.IDENTITY) + private Long id; + + @Column( nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private String qualifiedClassName; + + @Column(nullable = false) + private Timestamp createdAt; + + @Column(nullable = false) + private Timestamp updatedAt; + + @JoinColumn(name = "producer_id", referencedColumnName = "id") + @OneToOne(fetch = FetchType.LAZY) + private Producer producer; + + @Column(nullable = false) + private String propertyNameList; + + public Long getId() + { + return id; + } + + public void setId( Long id ) + { + this.id = id; + } + + public String getName() + { + return name; + } + + public void setName( String name ) + { + this.name = name; + } + + public String getQualifiedClassName() + { + return qualifiedClassName; + } + + public void setQualifiedClassName( String qualifiedClassName ) + { + this.qualifiedClassName = qualifiedClassName; + } + + public Timestamp getCreatedAt() + { + return createdAt; + } + + public void setCreatedAt( Timestamp createdAt ) + { + this.createdAt = createdAt; + } + + public Timestamp getUpdatedAt() + { + return updatedAt; + } + + public void setUpdatedAt( Timestamp updatedAt ) + { + this.updatedAt = updatedAt; + } + + public Producer getProducer() + { + return producer; + } + + public void setProducer( Producer producer ) + { + this.producer = producer; + } + + public String getPropertyNameList() + { + return propertyNameList; + } + + public void setPropertyNameList( String propertyNameList ) + { + this.propertyNameList = propertyNameList; + } + + public String[] getPropertyNameListAsArray() + { + return propertyNameList.split( "," ); + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/SerializerFormat.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/SerializerFormat.java new file mode 100644 index 00000000..73b44a90 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/SerializerFormat.java @@ -0,0 +1,123 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.model; + +import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +/** + * Represents entity that defines how to serialize data into kafka. + */ +@Entity +public class SerializerFormat implements UploadableJarEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private String classpath; + + @Column(nullable = false, unique = true) + private String jar; + + @Column(nullable = false) + private boolean isDefault = false; + + @Column(nullable = false) + private String optionParameters = "{}"; + + public long getId() { + return id; + } + + public void setId(final long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getClasspath() { + return classpath; + } + + public void setClasspath(final String classpath) { + this.classpath = classpath; + } + + public String getJar() { + return jar; + } + + public void setJar(final String jar) { + this.jar = jar; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(final boolean isDefault) { + this.isDefault = isDefault; + } + + public String getOptionParameters() { + return optionParameters; + } + + public void setOptionParameters(final String optionParameters) { + this.optionParameters = optionParameters; + } + + @Override + public UploadManager.UploadType getUploadType() { + return UploadManager.UploadType.SERIALIZER; + } + + @Override + public String toString() { + return "SerializerFormat{" + + "id=" + id + + ", name='" + name + '\'' + + ", classpath='" + classpath + '\'' + + ", jar='" + jar + '\'' + + ", isDefault=" + isDefault + + ", optionParameters='" + optionParameters + '\'' + + '}'; + } +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/UploadableJarEntity.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/UploadableJarEntity.java new file mode 100644 index 00000000..2588bffa --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/model/UploadableJarEntity.java @@ -0,0 +1,61 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.model; + +import org.sourcelab.kafka.webview.ui.manager.plugin.UploadManager; + +import java.beans.Transient; + +/** + * Common interface for records that represent uploadable jars. + */ +public interface UploadableJarEntity { + long getId(); + + void setId(final long id); + + String getName(); + + void setName(final String name); + + String getClasspath(); + + void setClasspath(final String classpath); + + String getJar(); + + void setJar(final String jar); + + boolean isDefault(); + + void setDefault(final boolean defaultFormat); + + String getOptionParameters(); + + void setOptionParameters(final String optionParameters); + + @Transient + UploadManager.UploadType getUploadType(); +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/MessageFormatRepository.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/MessageFormatRepository.java index b9cd68c4..3e1d6d3d 100644 --- a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/MessageFormatRepository.java +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/MessageFormatRepository.java @@ -25,31 +25,21 @@ package org.sourcelab.kafka.webview.ui.repository; import org.sourcelab.kafka.webview.ui.model.MessageFormat; -import org.springframework.data.repository.CrudRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; /** * For access records on the message_format table. */ @Repository -public interface MessageFormatRepository extends CrudRepository { - /** - * Retrieve by name. - * @param name Name to search for. - * @return MessageFormat found, or null. - */ - MessageFormat findByName(final String name); - - /** - * Find all message formats ordered by name. - * @return all Message Formats ordered by name. - */ - Iterable findAllByOrderByNameAsc(); +public interface MessageFormatRepository extends UploadableJarRepository { /** - * Find all message formats by type, ordered by name. - * @param isDefaultFormat Only return items that match the default_format field being true or false. + * Find all partitioning strategies by type, ordered by name. + * @param isDefault Only return items that match the is_default field being true or false. * @return all message formats ordered by name. */ - Iterable findByIsDefaultFormatOrderByNameAsc(final boolean isDefaultFormat); + @Override + @Query("SELECT f FROM MessageFormat f WHERE f.isDefaultFormat = :isDefault order by name asc") + Iterable findByIsDefaultOrderByNameAsc(final boolean isDefault); } diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/PartitioningStrategyRepository.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/PartitioningStrategyRepository.java new file mode 100644 index 00000000..c0e2361d --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/PartitioningStrategyRepository.java @@ -0,0 +1,36 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.repository; + +import org.sourcelab.kafka.webview.ui.model.PartitioningStrategy; +import org.springframework.stereotype.Repository; + +/** + * For access records on the message_format table. + */ +@Repository +public interface PartitioningStrategyRepository extends UploadableJarRepository { + +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerMessageRepository.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerMessageRepository.java new file mode 100644 index 00000000..7e761eb9 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerMessageRepository.java @@ -0,0 +1,38 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.repository; + +import org.sourcelab.kafka.webview.ui.model.Producer; +import org.sourcelab.kafka.webview.ui.model.ProducerMessage; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProducerMessageRepository extends CrudRepository +{ + Optional findByProducer(@Param("producer") Producer producer); +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerRepository.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerRepository.java new file mode 100644 index 00000000..4c88b4eb --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/ProducerRepository.java @@ -0,0 +1,42 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.sourcelab.kafka.webview.ui.repository; + +import org.sourcelab.kafka.webview.ui.model.Producer; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProducerRepository extends CrudRepository +{ + Producer findByName(final String name); + + Iterable findAllByOrderByNameAsc(); + + Iterable findAllByClusterIdOrderByNameAsc(final long clusterId); + + Long countByClusterId(final long clusterId); + + Producer findByTopic(final String topic); +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/SerializerFormatRepository.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/SerializerFormatRepository.java new file mode 100644 index 00000000..b8fdf722 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/SerializerFormatRepository.java @@ -0,0 +1,55 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.repository; + +import org.sourcelab.kafka.webview.ui.model.SerializerFormat; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +/** + * For access records on the message_format table. + */ +@Repository +public interface SerializerFormatRepository extends CrudRepository { + /** + * Retrieve by name. + * @param name Name to search for. + * @return SerializerFormat found, or null. + */ + SerializerFormat findByName(final String name); + + /** + * Find all message formats ordered by name. + * @return all serializer Formats ordered by name. + */ + Iterable findAllByOrderByNameAsc(); + + /** + * Find all message formats by type, ordered by name. + * @param isDefault Only return items that match the is_default field being true or false. + * @return all serializer formats ordered by name. + */ + Iterable findByIsDefaultOrderByNameAsc(final boolean isDefault); +} diff --git a/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/UploadableJarRepository.java b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/UploadableJarRepository.java new file mode 100644 index 00000000..15bfb6d9 --- /dev/null +++ b/kafka-webview-ui/src/main/java/org/sourcelab/kafka/webview/ui/repository/UploadableJarRepository.java @@ -0,0 +1,57 @@ +/** + * MIT License + * + * Copyright (c) 2017, 2018, 2019 SourceLab.org (https://github.com/SourceLabOrg/kafka-webview/) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.sourcelab.kafka.webview.ui.repository; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.NoRepositoryBean; + +/** + * Interface over uploadable jar entities. + * + * @param Entity type the repository operates on. + */ +@NoRepositoryBean +public interface UploadableJarRepository extends CrudRepository { + + /** + * Retrieve by name. + * @param name Name to search for. + * @return PartitioningStrategy found, or null. + */ + T findByName(final String name); + + /** + * Find all partitioning strategies ordered by name. + * @return all Partitioning Strategies ordered by name. + */ + Iterable findAllByOrderByNameAsc(); + + /** + * Find all partitioning strategies by type, ordered by name. + * @param isDefault Only return items that match the is_default field being true or false. + * @return all message formats ordered by name. + */ + Iterable findByIsDefaultOrderByNameAsc(final boolean isDefault); +} diff --git a/kafka-webview-ui/src/main/resources/schema/migration/h2/V3__ProducerTemplate.sql b/kafka-webview-ui/src/main/resources/schema/migration/h2/V3__ProducerTemplate.sql new file mode 100644 index 00000000..e2c46147 --- /dev/null +++ b/kafka-webview-ui/src/main/resources/schema/migration/h2/V3__ProducerTemplate.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS serializer_format +( + id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) UNIQUE NOT NULL, + classpath TEXT NOT NULL, + jar TEXT NOT NULL, + option_parameters TEXT NOT NULL DEFAULT '{}', + is_default BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by INT(11) UNSIGNED DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by INT(11) UNSIGNED DEFAULT NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS partitioning_strategy +( + id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) UNIQUE NOT NULL, + classpath TEXT NOT NULL, + jar TEXT NOT NULL, + option_parameters TEXT NOT NULL DEFAULT '{}', + is_default BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by INT(11) UNSIGNED DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by INT(11) UNSIGNED DEFAULT NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS producer_template ( + id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) UNIQUE NOT NULL, + cluster_id INT(11) UNSIGNED NOT NULL, + topic TEXT NOT NULL, + key_serializer_format_id INT(11) UNSIGNED NOT NULL, + value_serializer_format_id INT(11) UNSIGNED NOT NULL, + partitioning_strategy_id INT(11) UNSIGNED NOT NULL, + option TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by INT(11) UNSIGNED DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by INT(11) UNSIGNED DEFAULT NULL, + PRIMARY KEY (id), + FOREIGN KEY (cluster_id) REFERENCES cluster(id), + FOREIGN KEY (key_serializer_format_id) REFERENCES serializer_format(id), + FOREIGN KEY (value_serializer_format_id) REFERENCES serializer_format(id), + FOREIGN KEY (partitioning_strategy_id) REFERENCES partitioning_strategy(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/kafka-webview-ui/src/main/resources/schema/migration/h2/V4__producerTable.sql b/kafka-webview-ui/src/main/resources/schema/migration/h2/V4__producerTable.sql new file mode 100644 index 00000000..fca0eb30 --- /dev/null +++ b/kafka-webview-ui/src/main/resources/schema/migration/h2/V4__producerTable.sql @@ -0,0 +1,34 @@ +/* +TODO update producer table with the following when we want to send more than a map of string/string as a kafka message + key_message_format_id INT(11) UNSIGNED NOT NULL, + value_message_format_id INT(11) UNSIGNED NOT NULL, + FOREIGN KEY (key_message_format_id) REFERENCES message_format(id), + FOREIGN KEY (value_message_format_id) REFERENCES message_format(id) +*/ +CREATE TABLE IF NOT EXISTS `producer` ( + id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) UNIQUE NOT NULL, + cluster_id INT(11) UNSIGNED NOT NULL, + topic VARCHAR(150) UNIQUE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by INT(11) UNSIGNED DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by INT(11) UNSIGNED DEFAULT NULL, + PRIMARY KEY (id), + FOREIGN KEY (cluster_id) REFERENCES cluster(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `producer_message` ( + id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) UNIQUE NOT NULL, + qualified_class_name TEXT NOT NULL, + producer_id INT(11) UNSIGNED NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by INT(11) UNSIGNED DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_by INT(11) UNSIGNED DEFAULT NULL, + property_name_list TEXT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (producer_id) REFERENCES producer(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + diff --git a/kafka-webview-ui/src/main/resources/templates/configuration/index.html b/kafka-webview-ui/src/main/resources/templates/configuration/index.html index 29ff5efb..89667ad3 100644 --- a/kafka-webview-ui/src/main/resources/templates/configuration/index.html +++ b/kafka-webview-ui/src/main/resources/templates/configuration/index.html @@ -79,6 +79,15 @@ This section allows you to view any active stream consumers. + + + Producers + + + This section allows you to define how you would like write messages to a configured + Kafka cluster and topic. + + diff --git a/kafka-webview-ui/src/main/resources/templates/configuration/messageFormat/index.html b/kafka-webview-ui/src/main/resources/templates/configuration/messageFormat/index.html index e1a75cde..7fd0ffac 100644 --- a/kafka-webview-ui/src/main/resources/templates/configuration/messageFormat/index.html +++ b/kafka-webview-ui/src/main/resources/templates/configuration/messageFormat/index.html @@ -42,26 +42,26 @@ - + No Custom Message Formats Found. - - - - + + + +

+ + +
+ + +
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/kafka-webview-ui/src/main/resources/templates/configuration/partitionStrategy/index.html b/kafka-webview-ui/src/main/resources/templates/configuration/partitionStrategy/index.html new file mode 100644 index 00000000..2a000ac6 --- /dev/null +++ b/kafka-webview-ui/src/main/resources/templates/configuration/partitionStrategy/index.html @@ -0,0 +1,116 @@ + + + + + Partitioning Strategy Configuration + + + +
+
+ + +
+
+
+
+ + Custom Partition Strategies + +
+
+ + + + + + + + + + + + + + + + + + + + +
NameClassTypeAction
+ No Custom Partitioning Strategies Found. +
+ +
+
+
+
+ +
+ + +
+
+
+
+ + Default Partitioning Strategies +
+
+ + + + + + + + + + + + + + + +
NameClassType
+
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/kafka-webview-ui/src/main/resources/templates/configuration/producer/create.html b/kafka-webview-ui/src/main/resources/templates/configuration/producer/create.html new file mode 100644 index 00000000..1fe2cc25 --- /dev/null +++ b/kafka-webview-ui/src/main/resources/templates/configuration/producer/create.html @@ -0,0 +1,365 @@ + + + + + Producer Configuration + + + +
+
+ + +
+
+
+
+ Create + +
+
+ +
+ +
Topic Selection
+
+ + +
+ +
+ +
+
+
+ + +
+ +
+ +
+
+
+ + +
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ +
+ + +
+ +
+ +
+
+ +
+
+
+ + + + + + + +
+
+
+
+ + + + + +
+ + + \ No newline at end of file diff --git a/kafka-webview-ui/src/main/resources/templates/configuration/producer/index.html b/kafka-webview-ui/src/main/resources/templates/configuration/producer/index.html new file mode 100644 index 00000000..4a9b67ad --- /dev/null +++ b/kafka-webview-ui/src/main/resources/templates/configuration/producer/index.html @@ -0,0 +1,82 @@ + + + + + Producer Configuration + + + +
+
+
+
+
+
+ + Producers + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NameClusterTopicAction
+ No producer found! +
+ + + +
+
+
+
+ +
+
+
+ + + \ No newline at end of file diff --git a/kafka-webview-ui/src/main/resources/templates/layout.html b/kafka-webview-ui/src/main/resources/templates/layout.html index fe6aab51..07f51a64 100644 --- a/kafka-webview-ui/src/main/resources/templates/layout.html +++ b/kafka-webview-ui/src/main/resources/templates/layout.html @@ -147,6 +147,21 @@ + + +
  • +