From 689acfefab07515024fafa34438032f1bf1a61fa Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Wed, 23 Sep 2020 19:11:23 +0800 Subject: [PATCH 1/9] [TUBEMQ-355] Add business entity for topic manager (#273) --- tubemq-manager/pom.xml | 89 ++++++++++++ .../apache/tubemq/manager/TubeMQManager.java | 45 ++++++ .../manager/backend/AbstractDaemon.java | 97 +++++++++++++ .../manager/backend/ThreadStartAndStop.java | 37 +++++ .../manager/backend/TubeMQManagerFactory.java | 46 ++++++ .../controller/BusinessController.java | 93 ++++++++++++ .../manager/controller/BusinessResult.java | 28 ++++ .../tubemq/manager/entry/BusinessEntry.java | 136 ++++++++++++++++++ .../repository/BusinessRepository.java | 30 ++++ .../controller/TestBusinessController.java | 86 +++++++++++ .../repository/TestBusinessRepository.java | 67 +++++++++ 11 files changed, 754 insertions(+) create mode 100644 tubemq-manager/pom.xml create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java create mode 100644 tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java create mode 100644 tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java diff --git a/tubemq-manager/pom.xml b/tubemq-manager/pom.xml new file mode 100644 index 00000000000..0d33d82642a --- /dev/null +++ b/tubemq-manager/pom.xml @@ -0,0 +1,89 @@ + + + + + org.springframework.boot + spring-boot-starter-parent + 2.3.3.RELEASE + + 4.0.0 + tubemq-manager + + Apache TubeMQ - Manager + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-rest + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.projectlombok + lombok + + + + mysql + mysql-connector-java + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + org.assertj + assertj-core + 3.4.1 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java new file mode 100644 index 00000000000..5df581d3569 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tubemq.manager; + +import org.apache.tubemq.manager.backend.AbstractDaemon; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class TubeMQManager extends AbstractDaemon { + public static void main(String[] args) throws Exception { + TubeMQManager manager = new TubeMQManager(); + manager.startThreads(); + SpringApplication.run(TubeMQManager.class); + // web application stopped, then stop working threads. + manager.stopThreads(); + manager.join(); + } + + @Override + public void startThreads() throws Exception { + + } + + @Override + public void stopThreads() throws Exception { + + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java new file mode 100644 index 00000000000..2db9318151e --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java @@ -0,0 +1,97 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.backend; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract daemon with a batch of working thread. + */ +public abstract class AbstractDaemon implements ThreadStartAndStop { + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractDaemon.class); + + // worker thread pool + private final ExecutorService workerServices; + private final List> workerFutures; + private boolean runnable = true; + + public AbstractDaemon() { + this.workerServices = Executors + .newCachedThreadPool(new TubeMQManagerFactory(this.getClass().getSimpleName())); + this.workerFutures = new ArrayList<>(); + } + + /** + * Whether threads can in running state with while loop. + * + * @return - true if threads can run + */ + public boolean isRunnable() { + return runnable; + } + + /** + * Stop running threads. + */ + public void stopRunningThreads() { + runnable = false; + } + + /** + * Submit work thread to thread pool. + * + * @param worker - work thread + */ + public void submitWorker(Runnable worker) { + CompletableFuture future = CompletableFuture.runAsync(worker, this.workerServices); + workerFutures.add(future); + LOGGER.info("{} running worker number is {}", this.getClass().getName(), + workerFutures.size()); + } + + /** + * Wait for threads finish. + */ + public void join() { + for (CompletableFuture future : workerFutures) { + future.join(); + } + } + + /** + * Stop thread pool and running threads if they're in the running state. + * + * @param timeout - max wait time + * @param timeUnit - time unit + */ + public void waitForTerminate(long timeout, TimeUnit timeUnit) throws Exception { + // stopping working threads. + if (isRunnable()) { + stopRunningThreads(); + workerServices.shutdown(); + workerServices.awaitTermination(timeout, timeUnit); + } + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java new file mode 100644 index 00000000000..d3cacef87c2 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tubemq.manager.backend; + +/** + * Interface for starting and stopping backend threads. + */ +public interface ThreadStartAndStop { + /** + * start all threads. + */ + void startThreads() throws Exception; + + /** + * stop all threads. + */ + void stopThreads() throws Exception; + + /** + * wait for all thread finishing. + */ + void join() throws Exception; +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java new file mode 100644 index 00000000000..ca72901fe83 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.backend; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Thread factory for tubeMQ manager. + */ +public class TubeMQManagerFactory implements ThreadFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(TubeMQManagerFactory.class); + + private final AtomicInteger mThreadNum = new AtomicInteger(1); + + private final String threadType; + + public TubeMQManagerFactory(String threadType) { + this.threadType = threadType; + } + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, threadType + "-running-thread-" + mThreadNum.getAndIncrement()); + LOGGER.info("{} created", t.getName()); + return t; + } +} \ No newline at end of file diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java new file mode 100644 index 00000000000..934c215f429 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java @@ -0,0 +1,93 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tubemq.manager.controller; + +import java.util.List; +import java.util.Optional; +import org.apache.tubemq.manager.entry.BusinessEntry; +import org.apache.tubemq.manager.repository.BusinessRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/business") +public class BusinessController { + + @Autowired + private BusinessRepository businessRepository; + + /** + * add new business. + * + * @return - businessResult + * @throws Exception - exception + */ + @PostMapping("/add") + public ResponseEntity addBusiness(@RequestBody BusinessEntry entry) throws Exception { + // businessRepository.saveAndFlush(entry); + return ResponseEntity.ok().build(); + } + + /** + * update business + * + * @return + * @throws Exception + */ + @PostMapping("/update") + public ResponseEntity updateBusiness(@RequestBody BusinessEntry entry) throws Exception { + return ResponseEntity.ok().build(); + } + + /** + * Check business status by business name. + * + * @return + * @throws Exception + */ + @GetMapping("/check") + public ResponseEntity checkBusinessByName( + @RequestParam String businessName) throws Exception { + List result = businessRepository.findAllByBusinessName(businessName); + return ResponseEntity.ok().build(); + } + + /** + * get business by id. + * + * @param id business id + * @return BusinessResult + * @throws Exception + */ + @GetMapping("/get/{id}") + public ResponseEntity getBusinessByID( + @PathVariable Long id) throws Exception { + Optional businessEntry = businessRepository.findById(id); + if (businessEntry.isPresent()) { + return ResponseEntity.ok().build(); + } else { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java new file mode 100644 index 00000000000..6e4a6f6af32 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tubemq.manager.controller; + +import lombok.Data; + +/** + * rest result for business controller + */ +@Data +public class BusinessResult { + private int state; + private String msg; +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java new file mode 100644 index 00000000000..d56b0f280b9 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java @@ -0,0 +1,136 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.entry; + +import java.sql.Date; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import lombok.Data; +import org.hibernate.annotations.CreationTimestamp; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "business") +@Data +@EntityListeners(AuditingEntityListener.class) // support CreationTimestamp annotation +public class BusinessEntry { + @Id + @GeneratedValue(strategy=GenerationType.AUTO) + private long businessId; + + @Size(max = 30) + @NotNull + private String businessName; + + @Size(max = 64) + private String messageType; + + @Size(max = 256) + private String businessCnName; + + @Size(max = 256) + private String description; + + private String bg; + + @Size(max = 240) + @NotNull + private String schemaName; + + @Size(max = 32) + @NotNull + private String username; + + @Size(max = 64) + @NotNull + private String passwd; + + @Size(max = 64) + @NotNull + private String topic; + + @Size(max = 10) + private String fieldSplitter; + + @Size(max = 256) + private String predefinedFields; + + private int isHybridDataSource = 0; + + @Size(max = 64) + @NotNull + private String encodingType; + + private int isSubSort = 0; + + private String topologyName; + + private String targetServer; + + private String targetServerPort; + + private String netTarget; + + private int status; + + private String category; + + private int clusterId; + + private String inCharge; + + private String sourceServer; + + private String baseDir; + + @CreationTimestamp + private Date createTime; + + private String importType; + + private String exampleData; + + private String tdwAppgroup; + + @Column(name = "SN") + private int sn; + + @Size(max = 32) + private String issueMethod; + + private BusinessEntry() { + + } + + public BusinessEntry(String businessName, String schemaName, + String username, String passwd, String topic, String encodingType) { + this.businessName = businessName; + this.schemaName = schemaName; + this.username = username; + this.passwd = passwd; + this.topic = topic; + this.encodingType = encodingType; + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java new file mode 100644 index 00000000000..fa4f3afc50d --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.repository; + +import java.util.List; +import org.apache.tubemq.manager.entry.BusinessEntry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BusinessRepository extends JpaRepository { + List findAllByBusinessName(String businessName); + BusinessEntry findByBusinessName(String businessName); +} + diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java new file mode 100644 index 00000000000..e9340816897 --- /dev/null +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java @@ -0,0 +1,86 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tubemq.manager.controller; + +import java.net.URI; +import lombok.extern.slf4j.Slf4j; +import org.apache.tubemq.manager.entry.BusinessEntry; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@RunWith(SpringRunner.class) +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment= WebEnvironment.RANDOM_PORT) +@Slf4j +public class TestBusinessController { + + @Autowired + private TestRestTemplate client; + + @LocalServerPort + private int randomServerPort; + + private MockMvc mvc; + + @Before + public void setUp() { + mvc = MockMvcBuilders.standaloneSetup(new BusinessController()).build(); + } + + @Test + public void test404Controller() throws Exception { + RequestBuilder request; + // get request, path not exists + request = get("/business"); + mvc.perform(request) + .andExpect(status().isNotFound()); + } + + @Test + public void testAddBusiness() throws Exception { + final String baseUrl = "http://localhost:" + randomServerPort + "/business/add"; + URI uri = new URI(baseUrl); + String demoName = "test"; + BusinessEntry entry = new BusinessEntry(demoName, demoName, demoName, + demoName, demoName, demoName); + + HttpHeaders headers = new HttpHeaders(); + HttpEntity request = new HttpEntity<>(entry, headers); + + ResponseEntity responseEntity = + client.postForEntity(uri, request, ResponseEntity.class); + assertThat(responseEntity.getStatusCode().is2xxSuccessful()).isEqualTo(true); + } +} diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java new file mode 100644 index 00000000000..d51a9ed6ef5 --- /dev/null +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tubemq.manager.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import org.apache.tubemq.manager.entry.BusinessEntry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@DataJpaTest +public class TestBusinessRepository { + @Autowired + private TestEntityManager entityManager; + + @Autowired + private BusinessRepository businessRepository; + + @Test + public void whenFindByNameThenReturnBusiness() { + String demoName = "alex"; + BusinessEntry businessEntry = new BusinessEntry(demoName, demoName, + demoName, demoName, demoName, demoName); + entityManager.persist(businessEntry); + entityManager.flush(); + + BusinessEntry businessEntry1 = businessRepository.findByBusinessName("alex"); + assertThat(businessEntry1.getBusinessName()).isEqualTo(businessEntry.getBusinessName()); + } + + @Test + public void checkValidation() throws Exception { + String demoName = "a"; + BusinessEntry businessEntry = new BusinessEntry(demoName, demoName, demoName, + demoName, demoName, demoName); + StringBuilder builder = new StringBuilder(); + + for (int i = 0; i < 512; i ++) { + builder.append("a"); + } + businessEntry.setBusinessName(builder.toString()); + try { + entityManager.persist(businessEntry); + entityManager.flush(); + } catch (Exception ex) { + assertThat(ex.getMessage()).contains("size must be between"); + } + } +} From 1c177a7edee6d7506e961ab5b32064c595828c70 Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Sun, 27 Sep 2020 18:33:26 +0800 Subject: [PATCH 2/9] [TUBEMQ-364] uniform response format for exception state (#278) --- .../apache/tubemq/manager/TubeMQManager.java | 44 ++++++--- .../manager/backend/AbstractDaemon.java | 97 ------------------- .../controller/ManagerControllerAdvice.java | 46 +++++++++ .../{ => business}/BusinessController.java | 45 ++++++--- .../{ => business}/BusinessResult.java | 6 +- .../tubemq/manager/entry/BusinessEntry.java | 7 +- .../TubeMQManagerException.java} | 23 ++--- .../AsyncService.java} | 33 ++----- .../controller/TestBusinessController.java | 17 +++- 9 files changed, 142 insertions(+), 176 deletions(-) delete mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java rename tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/{ => business}/BusinessController.java (68%) rename tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/{ => business}/BusinessResult.java (89%) rename tubemq-manager/src/main/java/org/apache/tubemq/manager/{backend/ThreadStartAndStop.java => exceptions/TubeMQManagerException.java} (67%) rename tubemq-manager/src/main/java/org/apache/tubemq/manager/{backend/TubeMQManagerFactory.java => service/AsyncService.java} (50%) diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java index 5df581d3569..a25897beb8b 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java @@ -16,30 +16,44 @@ */ package org.apache.tubemq.manager; -import org.apache.tubemq.manager.backend.AbstractDaemon; +import java.util.concurrent.Executor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @SpringBootApplication @EnableJpaAuditing -public class TubeMQManager extends AbstractDaemon { - public static void main(String[] args) throws Exception { - TubeMQManager manager = new TubeMQManager(); - manager.startThreads(); - SpringApplication.run(TubeMQManager.class); - // web application stopped, then stop working threads. - manager.stopThreads(); - manager.join(); - } +@EnableAsync +public class TubeMQManager { - @Override - public void startThreads() throws Exception { + @Value("${manager.async.core.pool.size:2}") + private int asyncCorePoolSize; - } + @Value("${manager.async.max.pool.size:20}") + private int asyncMaxPoolSize; + + @Value("${manager.async.queue.capacity:100}") + private int asyncQueueCapacity; - @Override - public void stopThreads() throws Exception { + @Value("${manager.async.thread.prefix:AsyncThread-}") + private String threadPrefix; + + public static void main(String[] args) throws Exception { + SpringApplication.run(TubeMQManager.class); + } + @Bean(name = "asyncExecutor") + public Executor asyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(asyncCorePoolSize); + executor.setMaxPoolSize(asyncMaxPoolSize); + executor.setQueueCapacity(asyncQueueCapacity); + executor.setThreadNamePrefix(threadPrefix); + executor.initialize(); + return executor; } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java deleted file mode 100644 index 2db9318151e..00000000000 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/AbstractDaemon.java +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.tubemq.manager.backend; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Abstract daemon with a batch of working thread. - */ -public abstract class AbstractDaemon implements ThreadStartAndStop { - private static final Logger LOGGER = LoggerFactory.getLogger(AbstractDaemon.class); - - // worker thread pool - private final ExecutorService workerServices; - private final List> workerFutures; - private boolean runnable = true; - - public AbstractDaemon() { - this.workerServices = Executors - .newCachedThreadPool(new TubeMQManagerFactory(this.getClass().getSimpleName())); - this.workerFutures = new ArrayList<>(); - } - - /** - * Whether threads can in running state with while loop. - * - * @return - true if threads can run - */ - public boolean isRunnable() { - return runnable; - } - - /** - * Stop running threads. - */ - public void stopRunningThreads() { - runnable = false; - } - - /** - * Submit work thread to thread pool. - * - * @param worker - work thread - */ - public void submitWorker(Runnable worker) { - CompletableFuture future = CompletableFuture.runAsync(worker, this.workerServices); - workerFutures.add(future); - LOGGER.info("{} running worker number is {}", this.getClass().getName(), - workerFutures.size()); - } - - /** - * Wait for threads finish. - */ - public void join() { - for (CompletableFuture future : workerFutures) { - future.join(); - } - } - - /** - * Stop thread pool and running threads if they're in the running state. - * - * @param timeout - max wait time - * @param timeUnit - time unit - */ - public void waitForTerminate(long timeout, TimeUnit timeUnit) throws Exception { - // stopping working threads. - if (isRunnable()) { - stopRunningThreads(); - workerServices.shutdown(); - workerServices.awaitTermination(timeout, timeUnit); - } - } -} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java new file mode 100644 index 00000000000..33369ca6cda --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java @@ -0,0 +1,46 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.controller; +import javax.servlet.http.HttpServletRequest; +import org.apache.tubemq.manager.controller.business.BusinessResult; +import org.apache.tubemq.manager.exceptions.TubeMQManagerException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Controller advice for handling exceptions + */ +@RestControllerAdvice +public class ManagerControllerAdvice { + + /** + * handling business TubeMQManagerException, and return json format string. + * + * @param request - http request + * @param ex - exception + * @return entity + */ + @ExceptionHandler(TubeMQManagerException.class) + public BusinessResult handlingBusinessException(HttpServletRequest request, + TubeMQManagerException ex) { + BusinessResult result = new BusinessResult(); + result.setMessage(ex.getMessage()); + result.setCode(-1); + return result; + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessController.java similarity index 68% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessController.java index 934c215f429..c8190a8fb0b 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessController.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessController.java @@ -14,14 +14,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.tubemq.manager.controller; +package org.apache.tubemq.manager.controller.business; import java.util.List; import java.util.Optional; +import lombok.extern.slf4j.Slf4j; import org.apache.tubemq.manager.entry.BusinessEntry; +import org.apache.tubemq.manager.exceptions.TubeMQManagerException; import org.apache.tubemq.manager.repository.BusinessRepository; +import org.apache.tubemq.manager.service.AsyncService; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -32,11 +34,15 @@ @RestController @RequestMapping(path = "/business") +@Slf4j public class BusinessController { @Autowired private BusinessRepository businessRepository; + @Autowired + private AsyncService asyncService; + /** * add new business. * @@ -44,9 +50,9 @@ public class BusinessController { * @throws Exception - exception */ @PostMapping("/add") - public ResponseEntity addBusiness(@RequestBody BusinessEntry entry) throws Exception { - // businessRepository.saveAndFlush(entry); - return ResponseEntity.ok().build(); + public BusinessResult addBusiness(@RequestBody BusinessEntry entry) { + businessRepository.saveAndFlush(entry); + return new BusinessResult(); } /** @@ -56,8 +62,8 @@ public ResponseEntity addBusiness(@RequestBody BusinessEntry entry) throws Ex * @throws Exception */ @PostMapping("/update") - public ResponseEntity updateBusiness(@RequestBody BusinessEntry entry) throws Exception { - return ResponseEntity.ok().build(); + public BusinessResult updateBusiness(@RequestBody BusinessEntry entry) { + return new BusinessResult(); } /** @@ -67,10 +73,10 @@ public ResponseEntity updateBusiness(@RequestBody BusinessEntry entry) throws * @throws Exception */ @GetMapping("/check") - public ResponseEntity checkBusinessByName( - @RequestParam String businessName) throws Exception { + public BusinessResult checkBusinessByName( + @RequestParam String businessName) { List result = businessRepository.findAllByBusinessName(businessName); - return ResponseEntity.ok().build(); + return new BusinessResult(); } /** @@ -81,13 +87,20 @@ public ResponseEntity checkBusinessByName( * @throws Exception */ @GetMapping("/get/{id}") - public ResponseEntity getBusinessByID( - @PathVariable Long id) throws Exception { + public BusinessResult getBusinessByID( + @PathVariable Long id) { Optional businessEntry = businessRepository.findById(id); - if (businessEntry.isPresent()) { - return ResponseEntity.ok().build(); - } else { - return ResponseEntity.notFound().build(); + BusinessResult result = new BusinessResult(); + if (!businessEntry.isPresent()) { + result.setCode(-1); + result.setMessage("business not found"); } + return result; + } + + + @GetMapping("/throwException") + public BusinessResult throwException() { + throw new TubeMQManagerException("exception for test"); } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessResult.java similarity index 89% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessResult.java index 6e4a6f6af32..88c39ae723d 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/BusinessResult.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessResult.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.tubemq.manager.controller; +package org.apache.tubemq.manager.controller.business; import lombok.Data; @@ -23,6 +23,6 @@ */ @Data public class BusinessResult { - private int state; - private String msg; + private String message; + private int code = 0; } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java index d56b0f280b9..88e8e1e7eba 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java @@ -120,9 +120,6 @@ public class BusinessEntry { @Size(max = 32) private String issueMethod; - private BusinessEntry() { - - } public BusinessEntry(String businessName, String schemaName, String username, String passwd, String topic, String encodingType) { @@ -133,4 +130,8 @@ public BusinessEntry(String businessName, String schemaName, this.topic = topic; this.encodingType = encodingType; } + + public BusinessEntry() { + + } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/exceptions/TubeMQManagerException.java similarity index 67% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/exceptions/TubeMQManagerException.java index d3cacef87c2..46c888c8413 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/ThreadStartAndStop.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/exceptions/TubeMQManagerException.java @@ -14,24 +14,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.tubemq.manager.backend; + +package org.apache.tubemq.manager.exceptions; /** - * Interface for starting and stopping backend threads. + * TubeMQ runtime exception. */ -public interface ThreadStartAndStop { - /** - * start all threads. - */ - void startThreads() throws Exception; - - /** - * stop all threads. - */ - void stopThreads() throws Exception; +public class TubeMQManagerException extends RuntimeException { - /** - * wait for all thread finishing. - */ - void join() throws Exception; + public TubeMQManagerException(final String message) { + super(message); + } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/AsyncService.java similarity index 50% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/service/AsyncService.java index ca72901fe83..335f1d01376 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/backend/TubeMQManagerFactory.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/AsyncService.java @@ -15,32 +15,17 @@ * limitations under the License. */ -package org.apache.tubemq.manager.backend; +package org.apache.tubemq.manager.service; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicInteger; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; /** - * Thread factory for tubeMQ manager. + * Service for running async tasks. + * https://howtodoinjava.com/spring-boot2/rest/enableasync-async-controller/ */ -public class TubeMQManagerFactory implements ThreadFactory { +@Service +@Slf4j +public class AsyncService { - private static final Logger LOGGER = LoggerFactory.getLogger(TubeMQManagerFactory.class); - - private final AtomicInteger mThreadNum = new AtomicInteger(1); - - private final String threadType; - - public TubeMQManagerFactory(String threadType) { - this.threadType = threadType; - } - - @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r, threadType + "-running-thread-" + mThreadNum.getAndIncrement()); - LOGGER.info("{} created", t.getName()); - return t; - } -} \ No newline at end of file +} diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java index e9340816897..2ddfb67c0f6 100644 --- a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java @@ -17,7 +17,10 @@ package org.apache.tubemq.manager.controller; import java.net.URI; +import java.util.Objects; import lombok.extern.slf4j.Slf4j; +import org.apache.tubemq.manager.controller.business.BusinessController; +import org.apache.tubemq.manager.controller.business.BusinessResult; import org.apache.tubemq.manager.entry.BusinessEntry; import org.junit.Before; import org.junit.Test; @@ -79,8 +82,18 @@ public void testAddBusiness() throws Exception { HttpHeaders headers = new HttpHeaders(); HttpEntity request = new HttpEntity<>(entry, headers); - ResponseEntity responseEntity = - client.postForEntity(uri, request, ResponseEntity.class); + ResponseEntity responseEntity = + client.postForEntity(uri, request, BusinessResult.class); assertThat(responseEntity.getStatusCode().is2xxSuccessful()).isEqualTo(true); } + + @Test + public void testControllerException() throws Exception { + final String baseUrl = "http://localhost:" + randomServerPort + "/business/throwException"; + URI uri = new URI(baseUrl); + ResponseEntity responseEntity = + client.getForEntity(uri, BusinessResult.class); + assertThat(Objects.requireNonNull(responseEntity.getBody()).getCode()).isEqualTo(-1); + assertThat(responseEntity.getBody().getMessage()).isEqualTo("exception for test"); + } } From 714ea2b7e781ca612fe61d585d1eb26aeba829f2 Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Fri, 30 Oct 2020 14:05:05 +0800 Subject: [PATCH 3/9] [TUBEMQ-361] create topic when getting request (#292) --- tubemq-manager/pom.xml | 14 + .../controller/ManagerControllerAdvice.java | 6 +- .../TopicController.java} | 67 +++-- .../TopicResult.java} | 4 +- .../tubemq/manager/entry/NodeEntry.java | 51 ++++ .../{BusinessEntry.java => TopicEntry.java} | 8 +- .../tubemq/manager/entry/TopicStatus.java | 33 +++ ...essRepository.java => NodeRepository.java} | 10 +- .../manager/repository/TopicRepository.java | 43 +++ .../tubemq/manager/service/NodeService.java | 272 ++++++++++++++++++ .../manager/service/TopicBackendWorker.java | 137 +++++++++ .../tubemq/manager/service/TopicFuture.java | 58 ++++ .../tubemq/manager/service/TubeHttpConst.java | 30 ++ .../service/tube/TubeHttpBrokerInfoList.java | 135 +++++++++ .../TubeHttpResponse.java} | 17 +- .../service/tube/TubeHttpTopicInfoList.java | 97 +++++++ .../src/main/resources/application.properties | 17 ++ .../controller/TestBusinessController.java | 20 +- .../repository/TestBusinessRepository.java | 10 +- .../tube/TestTubeHttpBrokerResponse.java | 48 ++++ .../tube/TestTubeHttpTopicInfoList.java | 52 ++++ 21 files changed, 1066 insertions(+), 63 deletions(-) rename tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/{business/BusinessController.java => topic/TopicController.java} (54%) rename tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/{business/BusinessResult.java => topic/TopicResult.java} (91%) create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/NodeEntry.java rename tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/{BusinessEntry.java => TopicEntry.java} (95%) create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicStatus.java rename tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/{BusinessRepository.java => NodeRepository.java} (77%) create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/TopicRepository.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/service/NodeService.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicBackendWorker.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicFuture.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TubeHttpConst.java create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpBrokerInfoList.java rename tubemq-manager/src/main/java/org/apache/tubemq/manager/service/{AsyncService.java => tube/TubeHttpResponse.java} (74%) create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpTopicInfoList.java create mode 100644 tubemq-manager/src/main/resources/application.properties create mode 100644 tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpBrokerResponse.java create mode 100644 tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpTopicInfoList.java diff --git a/tubemq-manager/pom.xml b/tubemq-manager/pom.xml index 0d33d82642a..18e692ca104 100644 --- a/tubemq-manager/pom.xml +++ b/tubemq-manager/pom.xml @@ -33,6 +33,15 @@ org.springframework.boot spring-boot-starter-web + + org.apache.httpcomponents + httpclient + + + + org.apache.commons + commons-lang3 + org.springframework.boot @@ -44,6 +53,11 @@ spring-boot-starter-validation + + com.google.code.gson + gson + + org.springframework.boot spring-boot-starter-data-jpa diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java index 33369ca6cda..09a72cdcdfc 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java @@ -17,7 +17,7 @@ package org.apache.tubemq.manager.controller; import javax.servlet.http.HttpServletRequest; -import org.apache.tubemq.manager.controller.business.BusinessResult; +import org.apache.tubemq.manager.controller.topic.TopicResult; import org.apache.tubemq.manager.exceptions.TubeMQManagerException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -36,9 +36,9 @@ public class ManagerControllerAdvice { * @return entity */ @ExceptionHandler(TubeMQManagerException.class) - public BusinessResult handlingBusinessException(HttpServletRequest request, + public TopicResult handlingBusinessException(HttpServletRequest request, TubeMQManagerException ex) { - BusinessResult result = new BusinessResult(); + TopicResult result = new TopicResult(); result.setMessage(ex.getMessage()); result.setCode(-1); return result; diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessController.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java similarity index 54% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessController.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java index c8190a8fb0b..314d0799285 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessController.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java @@ -14,15 +14,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.tubemq.manager.controller.business; +package org.apache.tubemq.manager.controller.topic; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import lombok.extern.slf4j.Slf4j; -import org.apache.tubemq.manager.entry.BusinessEntry; +import org.apache.tubemq.manager.entry.TopicEntry; +import org.apache.tubemq.manager.entry.TopicStatus; import org.apache.tubemq.manager.exceptions.TubeMQManagerException; -import org.apache.tubemq.manager.repository.BusinessRepository; -import org.apache.tubemq.manager.service.AsyncService; +import org.apache.tubemq.manager.repository.TopicRepository; +import org.apache.tubemq.manager.service.TopicBackendWorker; +import org.apache.tubemq.manager.service.TopicFuture; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -35,62 +38,75 @@ @RestController @RequestMapping(path = "/business") @Slf4j -public class BusinessController { +public class TopicController { @Autowired - private BusinessRepository businessRepository; + private TopicRepository topicRepository; @Autowired - private AsyncService asyncService; + private TopicBackendWorker topicBackendWorker; /** - * add new business. + * add new topic. * * @return - businessResult * @throws Exception - exception */ @PostMapping("/add") - public BusinessResult addBusiness(@RequestBody BusinessEntry entry) { - businessRepository.saveAndFlush(entry); - return new BusinessResult(); + public TopicResult addTopic(@RequestBody TopicEntry entry) { + // entry in adding status + entry.setStatus(TopicStatus.ADDING.value()); + topicRepository.saveAndFlush(entry); + CompletableFuture future = new CompletableFuture<>(); + topicBackendWorker.addTopicFuture(new TopicFuture(entry, future)); + future.whenComplete((entry1, throwable) -> { + entry1.setStatus(TopicStatus.SUCCESS.value()); + if (throwable != null) { + // if throwable is not success, mark it as failed. + entry1.setStatus(TopicStatus.FAILED.value()); + log.error("exception caught", throwable); + } + topicRepository.saveAndFlush(entry1); + }); + return new TopicResult(); } /** - * update business + * update topic * * @return * @throws Exception */ @PostMapping("/update") - public BusinessResult updateBusiness(@RequestBody BusinessEntry entry) { - return new BusinessResult(); + public TopicResult updateTopic(@RequestBody TopicEntry entry) { + return new TopicResult(); } /** - * Check business status by business name. + * Check topic status by business name. * * @return * @throws Exception */ @GetMapping("/check") - public BusinessResult checkBusinessByName( + public TopicResult checkTopicByBusinessName( @RequestParam String businessName) { - List result = businessRepository.findAllByBusinessName(businessName); - return new BusinessResult(); + List result = topicRepository.findAllByBusinessName(businessName); + return new TopicResult(); } /** - * get business by id. + * get topic by id. * * @param id business id * @return BusinessResult * @throws Exception */ @GetMapping("/get/{id}") - public BusinessResult getBusinessByID( + public TopicResult getBusinessByID( @PathVariable Long id) { - Optional businessEntry = businessRepository.findById(id); - BusinessResult result = new BusinessResult(); + Optional businessEntry = topicRepository.findById(id); + TopicResult result = new TopicResult(); if (!businessEntry.isPresent()) { result.setCode(-1); result.setMessage("business not found"); @@ -98,9 +114,12 @@ public BusinessResult getBusinessByID( return result; } - + /** + * test for exception situation. + * @return + */ @GetMapping("/throwException") - public BusinessResult throwException() { + public TopicResult throwException() { throw new TubeMQManagerException("exception for test"); } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessResult.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicResult.java similarity index 91% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessResult.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicResult.java index 88c39ae723d..98fb81e9b63 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/business/BusinessResult.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicResult.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.tubemq.manager.controller.business; +package org.apache.tubemq.manager.controller.topic; import lombok.Data; @@ -22,7 +22,7 @@ * rest result for business controller */ @Data -public class BusinessResult { +public class TopicResult { private String message; private int code = 0; } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/NodeEntry.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/NodeEntry.java new file mode 100644 index 00000000000..54c4236849e --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/NodeEntry.java @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.entry; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import lombok.Data; + +/** + * node machine for tube cluster. broker/master/standby + */ +@Entity +@Table(name = "node") +@Data +public class NodeEntry { + @Id + @GeneratedValue(strategy= GenerationType.AUTO) + private long brokerId; + + private boolean master; + + private boolean standby; + + private boolean broker; + + private String ip; + + private int port; + + private int webPort; + + private int clusterId; +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicEntry.java similarity index 95% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicEntry.java index 88e8e1e7eba..17b7711e115 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/BusinessEntry.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicEntry.java @@ -32,10 +32,10 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Entity -@Table(name = "business") +@Table(name = "topic") @Data @EntityListeners(AuditingEntityListener.class) // support CreationTimestamp annotation -public class BusinessEntry { +public class TopicEntry { @Id @GeneratedValue(strategy=GenerationType.AUTO) private long businessId; @@ -121,7 +121,7 @@ public class BusinessEntry { private String issueMethod; - public BusinessEntry(String businessName, String schemaName, + public TopicEntry(String businessName, String schemaName, String username, String passwd, String topic, String encodingType) { this.businessName = businessName; this.schemaName = schemaName; @@ -131,7 +131,7 @@ public BusinessEntry(String businessName, String schemaName, this.encodingType = encodingType; } - public BusinessEntry() { + public TopicEntry() { } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicStatus.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicStatus.java new file mode 100644 index 00000000000..e5796af6486 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/entry/TopicStatus.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.entry; + +public enum TopicStatus { + + ADDING(0), SUCCESS(1), FAILED(2), RETRY(3); + + private int value = 0; + + private TopicStatus(int value) { + this.value = value; + } + + public int value() { + return this.value; + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/NodeRepository.java similarity index 77% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/NodeRepository.java index fa4f3afc50d..4bf6ec78799 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/BusinessRepository.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/NodeRepository.java @@ -17,14 +17,12 @@ package org.apache.tubemq.manager.repository; -import java.util.List; -import org.apache.tubemq.manager.entry.BusinessEntry; +import org.apache.tubemq.manager.entry.NodeEntry; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface BusinessRepository extends JpaRepository { - List findAllByBusinessName(String businessName); - BusinessEntry findByBusinessName(String businessName); -} +public interface NodeRepository extends JpaRepository { + NodeEntry findNodeEntryByClusterIdIsAndMasterIsTrue(int clusterId); +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/TopicRepository.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/TopicRepository.java new file mode 100644 index 00000000000..4c889497434 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/repository/TopicRepository.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.repository; + +import java.util.List; +import org.apache.tubemq.manager.entry.TopicEntry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TopicRepository extends JpaRepository { + + /** + * get all topicEntry list by business name + * @param businessName + * @return + */ + List findAllByBusinessName(String businessName); + + /** + * get one topicEntry by business name + * @param businessName + * @return + */ + TopicEntry findByBusinessName(String businessName); + +} + diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/NodeService.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/NodeService.java new file mode 100644 index 00000000000..4e0db3e5a40 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/NodeService.java @@ -0,0 +1,272 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service; + + +import static org.apache.tubemq.manager.service.TubeHttpConst.ADD_TUBE_TOPIC; +import static org.apache.tubemq.manager.service.TubeHttpConst.BROKER_RUN_STATUS; +import static org.apache.tubemq.manager.service.TubeHttpConst.RELOAD_BROKER; +import static org.apache.tubemq.manager.service.TubeHttpConst.SCHEMA; +import static org.apache.tubemq.manager.service.TubeHttpConst.TOPIC_CONFIG_INFO; + +import com.google.gson.Gson; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.tubemq.manager.entry.NodeEntry; +import org.apache.tubemq.manager.repository.NodeRepository; +import org.apache.tubemq.manager.service.tube.TubeHttpBrokerInfoList; +import org.apache.tubemq.manager.service.tube.TubeHttpResponse; +import org.apache.tubemq.manager.service.tube.TubeHttpTopicInfoList; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +/** + * node service to query broker/master/standby status of tube cluster. + */ +@Slf4j +public class NodeService { + + private final CloseableHttpClient httpclient = HttpClients.createDefault(); + private final Gson gson = new Gson(); + + @Value("${manager.max.configurable.broker.size:50}") + private int maxConfigurableBrokerSize; + + @Value("${manager.max.retry.adding.topic:10}") + private int maxRetryAddingTopic; + + private final TopicBackendWorker worker; + + @Autowired + private NodeRepository nodeRepository; + + public NodeService(TopicBackendWorker worker) { + this.worker = worker; + } + + /** + * request node status via http. + * + * @param nodeEntry - node entry + * @return + * @throws IOException + */ + private TubeHttpBrokerInfoList requestClusterNodeStatus(NodeEntry nodeEntry) throws IOException { + String url = SCHEMA + nodeEntry.getIp() + ":" + nodeEntry.getWebPort() + BROKER_RUN_STATUS; + HttpGet httpget = new HttpGet(url); + try (CloseableHttpResponse response = httpclient.execute(httpget)) { + TubeHttpBrokerInfoList brokerInfoList = + gson.fromJson(new InputStreamReader(response.getEntity().getContent()), + TubeHttpBrokerInfoList.class); + // request return normal. + if (brokerInfoList.getCode() == 0) { + // divide by state. + brokerInfoList.divideBrokerListByState(); + return brokerInfoList; + } + } catch (Exception ex) { + log.error("exception caught while requesting broker status", ex); + } + return null; + } + + + private TubeHttpTopicInfoList requestTopicConfigInfo(NodeEntry nodeEntry, String topic) { + String url = SCHEMA + nodeEntry.getIp() + ":" + nodeEntry.getWebPort() + + TOPIC_CONFIG_INFO + "&topicName=" + topic; + HttpGet httpget = new HttpGet(url); + try (CloseableHttpResponse response = httpclient.execute(httpget)) { + TubeHttpTopicInfoList topicInfoList = + gson.fromJson(new InputStreamReader(response.getEntity().getContent()), + TubeHttpTopicInfoList.class); + if (topicInfoList.getErrCode() == 0) { + return topicInfoList; + } + } catch (Exception ex) { + log.error("exception caught while requesting broker status", ex); + } + return null; + } + + + private boolean configBrokersForTopics(NodeEntry nodeEntry, + Set topics, List brokerList, int maxBrokers) { + List finalBrokerList = brokerList.subList(0, maxBrokers); + String brokerStr = StringUtils.join(finalBrokerList, ","); + String topicStr = StringUtils.join(topics, ","); + String url = SCHEMA + nodeEntry.getIp() + ":" + nodeEntry.getWebPort() + + ADD_TUBE_TOPIC + "&topicName=" + topicStr + "&brokerId=" + brokerStr; + HttpGet httpget = new HttpGet(url); + try (CloseableHttpResponse response = httpclient.execute(httpget)) { + TubeHttpResponse result = + gson.fromJson(new InputStreamReader(response.getEntity().getContent()), + TubeHttpResponse.class); + return result.getCode() == 0 && result.getErrCode() == 0; + } catch (Exception ex) { + log.error("exception caught while requesting broker status", ex); + } + return false; + } + + /** + * handle result, if success, complete it, + * if not success, add back to queue without exceeding max retry, + * otherwise complete it with exception. + * + * @param isSuccess + * @param topics + * @param pendingTopic + */ + private void handleAddingResult(boolean isSuccess, Set topics, + Map pendingTopic) { + for (String topic : topics) { + TopicFuture future = pendingTopic.get(topic); + if (future != null) { + if (isSuccess) { + future.complete(); + } else { + future.increaseRetryTime(); + if (future.getRetryTime() > maxRetryAddingTopic) { + future.completeExceptional(); + } else { + // add back to queue. + worker.addTopicFuture(future); + } + } + } + } + } + + + /** + * Adding topic is an async operation, so this method should + * 1. check whether pendingTopic contains topic that has failed/succeeded to be added. + * 2. async add topic to tubemq cluster + * + * @param brokerInfoList - broker list + * @param pendingTopic - topicMap + */ + private void handleAddingTopic(NodeEntry nodeEntry, + TubeHttpBrokerInfoList brokerInfoList, + Map pendingTopic) { + // 1. check tubemq cluster by topic name, remove pending topic if has added. + Set brandNewTopics = new HashSet<>(); + for (String topic : pendingTopic.keySet()) { + TubeHttpTopicInfoList topicInfoList = requestTopicConfigInfo(nodeEntry, topic); + if (topicInfoList != null) { + // get broker list by topic request + List topicBrokerList = topicInfoList.getTopicBrokerIdList(); + if (topicBrokerList.isEmpty()) { + brandNewTopics.add(topic); + } else { + // remove brokers which have been added. + List configurableBrokerIdList = + brokerInfoList.getConfigurableBrokerIdList(); + configurableBrokerIdList.removeAll(topicBrokerList); + // add topic to satisfy max broker number. + Set singleTopic = new HashSet<>(); + singleTopic.add(topic); + int maxBrokers = maxConfigurableBrokerSize - topicBrokerList.size(); + boolean isSuccess = configBrokersForTopics(nodeEntry, singleTopic, + configurableBrokerIdList, maxBrokers); + handleAddingResult(isSuccess, singleTopic, pendingTopic); + } + } + } + // 2. add new topics to cluster + List configurableBrokerIdList = brokerInfoList.getConfigurableBrokerIdList(); + int maxBrokers = Math.min(maxConfigurableBrokerSize, configurableBrokerIdList.size()); + boolean isSuccess = configBrokersForTopics(nodeEntry, brandNewTopics, + configurableBrokerIdList, maxBrokers); + handleAddingResult(isSuccess, brandNewTopics, pendingTopic); + } + + /** + * reload broker list, cannot exceed maxConfigurableBrokerSize each time. + * + * @param nodeEntry + * @param needReloadList + */ + private void handleReloadBroker(NodeEntry nodeEntry, List needReloadList) { + // reload without exceed max broker. + int begin = 0; + int end = 0; + do { + end = Math.min(maxConfigurableBrokerSize + begin, needReloadList.size()); + List brokerIdList = needReloadList.subList(begin, end); + String brokerStr = StringUtils.join(brokerIdList, ","); + String url = SCHEMA + nodeEntry.getIp() + ":" + nodeEntry.getWebPort() + + RELOAD_BROKER + "&brokerId=" + brokerStr; + HttpGet httpget = new HttpGet(url); + try (CloseableHttpResponse response = httpclient.execute(httpget)) { + TubeHttpResponse result = + gson.fromJson(new InputStreamReader(response.getEntity().getContent()), + TubeHttpResponse.class); + if (result.getErrCode() == 0 && result.getCode() == 0) { + log.info("reload tube broker cgi: " + + url + " ; return value : " + result.getCode()); + } + } catch (Exception ex) { + log.error("exception caught while requesting broker status", ex); + } + begin = end; + } while (end >= needReloadList.size()); + } + + + + /** + * update broker status + */ + public void updateBrokerStatus(int clusterId, Map pendingTopic) { + NodeEntry nodeEntry = nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(clusterId); + if (nodeEntry != null) { + try { + TubeHttpBrokerInfoList brokerInfoList = requestClusterNodeStatus(nodeEntry); + if (brokerInfoList != null) { + handleAddingTopic(nodeEntry, brokerInfoList, pendingTopic); + } + + // refresh broker list + brokerInfoList = requestClusterNodeStatus(nodeEntry); + if (brokerInfoList != null) { + handleReloadBroker(nodeEntry, brokerInfoList.getNeedReloadList()); + } + + } catch (Exception ex) { + log.error("exception caught while requesting broker status", ex); + } + } else { + log.error("cannot get master ip by clusterId {}, please check it", clusterId); + } + } + + public void close() throws IOException { + httpclient.close(); + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicBackendWorker.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicBackendWorker.java new file mode 100644 index 00000000000..86b72d52213 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicBackendWorker.java @@ -0,0 +1,137 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.apache.tubemq.manager.repository.TopicRepository; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Topic backend thread worker. + */ +@Component +@Slf4j +public class TopicBackendWorker implements DisposableBean, Runnable { + private final AtomicBoolean runFlag = new AtomicBoolean(true); + private final ConcurrentHashMap> pendingTopics = + new ConcurrentHashMap<>(); + private final AtomicInteger notSatisfiedCount = new AtomicInteger(0); + private final NodeService nodeService; + + @Autowired + private TopicRepository topicRepository; + + @Value("${manager.topic.queue.warning.size:100}") + private int queueWarningSize; + + // value in seconds + @Value("${manager.topic.queue.thread.interval:10}") + private int queueThreadInterval; + + @Value("${manager.topic.queue.max.wait:3}") + private int queueMaxWait; + + @Value("${manager.topic.queue.max.running.size:20}") + private int queueMaxRunningSize; + + TopicBackendWorker() { + Thread thread = new Thread(this); + // daemon thread + thread.setDaemon(true); + thread.start(); + nodeService = new NodeService(this); + } + + /** + * add topic future to pending executing queue. + * @param future - TopicFuture. + */ + public void addTopicFuture(TopicFuture future) { + BlockingQueue tmpQueue = new LinkedBlockingQueue<>(); + BlockingQueue queue = pendingTopics.putIfAbsent( + future.getEntry().getClusterId(), tmpQueue); + if (queue == null) { + queue = tmpQueue; + } + queue.add(future); + if (queue.size() > queueWarningSize) { + log.warn("queue size exceed {}, please check it", queueWarningSize); + } + } + + /** + * batch executing adding topic, wait util max n seconds or max size satisfied. + */ + private void batchAddTopic() { + pendingTopics.forEach((clusterId, queue) -> { + Map pendingTopicList = new HashMap<>(); + if (notSatisfiedCount.get() > queueMaxWait || queue.size() > queueMaxRunningSize) { + notSatisfiedCount.set(0); + List tmpTopicList = new ArrayList<>(); + queue.drainTo(tmpTopicList, queueMaxRunningSize); + for (TopicFuture topicFuture : tmpTopicList) { + pendingTopicList.put(topicFuture.getEntry().getTopic(), topicFuture); + } + } else { + notSatisfiedCount.incrementAndGet(); + } + // update broker status + nodeService.updateBrokerStatus(clusterId, pendingTopicList); + }); + + } + + /** + * check topic from db + */ + private void checkTopicFromDB() { + } + + @Override + public void run() { + log.info("TopicBackendWorker has started"); + while (runFlag.get()) { + try { + batchAddTopic(); + checkTopicFromDB(); + TimeUnit.SECONDS.sleep(queueThreadInterval); + } catch (Exception exception) { + log.warn("exception caught", exception); + } + } + } + + @Override + public void destroy() throws Exception { + runFlag.set(false); + nodeService.close(); + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicFuture.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicFuture.java new file mode 100644 index 00000000000..62b0e2d9dee --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TopicFuture.java @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service; + +import java.util.concurrent.CompletableFuture; +import lombok.Getter; +import org.apache.tubemq.manager.entry.TopicEntry; + +/** + * topic business with future. + */ +public class TopicFuture { + @Getter + private int retryTime = 0; + @Getter + private final TopicEntry entry; + @Getter + private final CompletableFuture future; + + public TopicFuture(TopicEntry entry, CompletableFuture future) { + this.entry = entry; + this.future = future; + } + + /** + * record retry time. + */ + public void increaseRetryTime() { + retryTime += 1; + } + + /** + * when topic operation finished, complete it. + */ + public void complete() { + this.future.complete(this.entry); + } + + public void completeExceptional() { + this.future.completeExceptionally(new RuntimeException("exceed max retry " + + retryTime +" adding")); + } +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TubeHttpConst.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TubeHttpConst.java new file mode 100644 index 00000000000..81a360e26e3 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/TubeHttpConst.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service; + +public class TubeHttpConst { + public static final String SCHEMA = "http://"; + public static final String BROKER_RUN_STATUS = + "/webapi.htm?type=op_query&method=admin_query_broker_run_status"; + public static final String TOPIC_CONFIG_INFO = + "/webapi.htm?type=op_query&method=admin_query_topic_info"; + public static final String ADD_TUBE_TOPIC = + "/webapi.htm?type=op_modify&method=admin_add_new_topic_record"; + public static final String RELOAD_BROKER = + "/webapi.htm?type=op_modify&method=admin_reload_broker_configure"; +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpBrokerInfoList.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpBrokerInfoList.java new file mode 100644 index 00000000000..c768aa168ad --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpBrokerInfoList.java @@ -0,0 +1,135 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service.tube; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * json class for broker info list from master http service. + */ +@Data +public class TubeHttpBrokerInfoList { + + /** + * json class for broker info. + */ + @Data + public static class BrokerInfo { + private int brokerId; + private String brokerIp; + private int brokerPort; + private String manageStatus; + private String runStatus; + private String subStatus; + private int stepOp; + private boolean isConfChanged; + private boolean isConfLoaded; + private boolean isBrokerOnline; + private String brokerVersion; + private boolean acceptPublish; + private boolean acceptSubscribe; + + public boolean isIdle() { + return subStatus != null && subStatus.equals("idle"); + } + + public boolean isWorking() { + if (runStatus != null && manageStatus != null) { + return runStatus.equals("running") && ( + manageStatus.equals("online") || + manageStatus.equals("only-read") || + manageStatus.equals("only-write")); + } + return false; + } + + public boolean isConfigurable() { + return stepOp == -2 || stepOp == 31 || stepOp == 32; + } + + @Override + public int hashCode() { + return brokerId; + } + + @Override + public boolean equals(Object o) { + + if (o == this) return true; + if (!(o instanceof BrokerInfo)) { + return false; + } + + BrokerInfo brokerInfo = (BrokerInfo) o; + + return brokerId == brokerInfo.brokerId; + } + } + + private int code; + private String errMsg; + // total broker info list of brokers. + private List data; + // configurable list of brokers. + private List configurableList; + // working state list of brokers + private List workingList; + // idle broker list + private List idleList; + // need reload broker list + private List needReloadList; + + /** + * divide broker list into different list by broker state. + */ + public void divideBrokerListByState() { + if (data != null) { + configurableList = new ArrayList<>(); + workingList = new ArrayList<>(); + idleList = new ArrayList<>(); + needReloadList = new ArrayList<>(); + for (BrokerInfo brokerInfo : data) { + if (brokerInfo.isConfigurable()) { + configurableList.add(brokerInfo); + } + if (brokerInfo.isWorking()) { + workingList.add(brokerInfo); + } + if (brokerInfo.isIdle()) { + idleList.add(brokerInfo); + } + if (brokerInfo.isConfChanged) { + needReloadList.add(brokerInfo.getBrokerId()); + } + } + } + } + + public List getConfigurableBrokerIdList() { + List tmpBrokerIdList = new ArrayList<>(); + if (configurableList != null) { + for (BrokerInfo brokerInfo : configurableList) { + tmpBrokerIdList.add(brokerInfo.getBrokerId()); + } + } + return tmpBrokerIdList; + } + +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/AsyncService.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpResponse.java similarity index 74% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/service/AsyncService.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpResponse.java index 335f1d01376..bc30025a219 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/AsyncService.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpResponse.java @@ -15,17 +15,16 @@ * limitations under the License. */ -package org.apache.tubemq.manager.service; +package org.apache.tubemq.manager.service.tube; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; +import lombok.Data; /** - * Service for running async tasks. - * https://howtodoinjava.com/spring-boot2/rest/enableasync-async-controller/ + * common response json str for tube htt request */ -@Service -@Slf4j -public class AsyncService { - +@Data +public class TubeHttpResponse { + private int code; + private String errMsg; + private int errCode; } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpTopicInfoList.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpTopicInfoList.java new file mode 100644 index 00000000000..7131b83c792 --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/service/tube/TubeHttpTopicInfoList.java @@ -0,0 +1,97 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service.tube; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; +import org.apache.tubemq.manager.service.tube.TubeHttpTopicInfoList.TopicInfoList.TopicInfo; + +/** + * json class for topic info list from master http service. + */ +@Data +public class TubeHttpTopicInfoList { + private boolean result; + + private String errMsg; + + private int errCode; + + private List data; + + @Data + public static class TopicInfoList { + + @Data + public static class TopicInfo { + + @Data + public static class RunInfo { + private boolean acceptPublish; + private boolean acceptSubscribe; + private int numPartitions; + private int numTopicStores; + private String brokerManageStatus; + } + + + private String topicName; + private int topicStatusId; + private int brokerId; + private String brokerIp; + private int brokerPort; + private int numPartitions; + private int unflushThreshold; + private int unflushInterval; + private int unFlushDataHold; + private String deleteWhen; + private String deletePolicy; + private boolean acceptPublish; + private boolean acceptSubscribe; + private int numTopicStores; + private int memCacheMsgSizeInMB; + private int memCacheFlushIntvl; + private int memCacheMsgCntInK; + private String createUser; + private String createDate; + private String modifyUser; + private String modifyDate; + private RunInfo runInfo; + + } + + private String topicName; + private List topicInfo; + } + + + public List getTopicBrokerIdList() { + List tmpBrokerIdList = new ArrayList<>(); + if (data != null) { + for (TopicInfoList topicInfoList : data) { + if (topicInfoList.getTopicInfo() != null) { + for (TopicInfo topicInfo : topicInfoList.getTopicInfo()) { + tmpBrokerIdList.add(topicInfo.getBrokerId()); + } + } + } + } + return tmpBrokerIdList; + } +} diff --git a/tubemq-manager/src/main/resources/application.properties b/tubemq-manager/src/main/resources/application.properties new file mode 100644 index 00000000000..dee51b78f21 --- /dev/null +++ b/tubemq-manager/src/main/resources/application.properties @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +#

+# http://www.apache.org/licenses/LICENSE-2.0 +#

+# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +spring.jpa.hibernate.ddl-auto=update +# configuration for manager diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java index 2ddfb67c0f6..0838203085c 100644 --- a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java @@ -19,9 +19,9 @@ import java.net.URI; import java.util.Objects; import lombok.extern.slf4j.Slf4j; -import org.apache.tubemq.manager.controller.business.BusinessController; -import org.apache.tubemq.manager.controller.business.BusinessResult; -import org.apache.tubemq.manager.entry.BusinessEntry; +import org.apache.tubemq.manager.controller.topic.TopicController; +import org.apache.tubemq.manager.controller.topic.TopicResult; +import org.apache.tubemq.manager.entry.TopicEntry; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -59,7 +59,7 @@ public class TestBusinessController { @Before public void setUp() { - mvc = MockMvcBuilders.standaloneSetup(new BusinessController()).build(); + mvc = MockMvcBuilders.standaloneSetup(new TopicController()).build(); } @Test @@ -76,14 +76,14 @@ public void testAddBusiness() throws Exception { final String baseUrl = "http://localhost:" + randomServerPort + "/business/add"; URI uri = new URI(baseUrl); String demoName = "test"; - BusinessEntry entry = new BusinessEntry(demoName, demoName, demoName, + TopicEntry entry = new TopicEntry(demoName, demoName, demoName, demoName, demoName, demoName); HttpHeaders headers = new HttpHeaders(); - HttpEntity request = new HttpEntity<>(entry, headers); + HttpEntity request = new HttpEntity<>(entry, headers); - ResponseEntity responseEntity = - client.postForEntity(uri, request, BusinessResult.class); + ResponseEntity responseEntity = + client.postForEntity(uri, request, TopicResult.class); assertThat(responseEntity.getStatusCode().is2xxSuccessful()).isEqualTo(true); } @@ -91,8 +91,8 @@ public void testAddBusiness() throws Exception { public void testControllerException() throws Exception { final String baseUrl = "http://localhost:" + randomServerPort + "/business/throwException"; URI uri = new URI(baseUrl); - ResponseEntity responseEntity = - client.getForEntity(uri, BusinessResult.class); + ResponseEntity responseEntity = + client.getForEntity(uri, TopicResult.class); assertThat(Objects.requireNonNull(responseEntity.getBody()).getCode()).isEqualTo(-1); assertThat(responseEntity.getBody().getMessage()).isEqualTo("exception for test"); } diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java index d51a9ed6ef5..7bd8c739550 100644 --- a/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/repository/TestBusinessRepository.java @@ -17,7 +17,7 @@ package org.apache.tubemq.manager.repository; import static org.assertj.core.api.Assertions.assertThat; -import org.apache.tubemq.manager.entry.BusinessEntry; +import org.apache.tubemq.manager.entry.TopicEntry; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -32,24 +32,24 @@ public class TestBusinessRepository { private TestEntityManager entityManager; @Autowired - private BusinessRepository businessRepository; + private TopicRepository businessRepository; @Test public void whenFindByNameThenReturnBusiness() { String demoName = "alex"; - BusinessEntry businessEntry = new BusinessEntry(demoName, demoName, + TopicEntry businessEntry = new TopicEntry(demoName, demoName, demoName, demoName, demoName, demoName); entityManager.persist(businessEntry); entityManager.flush(); - BusinessEntry businessEntry1 = businessRepository.findByBusinessName("alex"); + TopicEntry businessEntry1 = businessRepository.findByBusinessName("alex"); assertThat(businessEntry1.getBusinessName()).isEqualTo(businessEntry.getBusinessName()); } @Test public void checkValidation() throws Exception { String demoName = "a"; - BusinessEntry businessEntry = new BusinessEntry(demoName, demoName, demoName, + TopicEntry businessEntry = new TopicEntry(demoName, demoName, demoName, demoName, demoName, demoName); StringBuilder builder = new StringBuilder(); diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpBrokerResponse.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpBrokerResponse.java new file mode 100644 index 00000000000..2d79f69f7ff --- /dev/null +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpBrokerResponse.java @@ -0,0 +1,48 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service.tube; + +import com.google.gson.Gson; +import lombok.extern.slf4j.Slf4j; +import org.junit.Assert; +import org.junit.Test; + +@Slf4j +public class TestTubeHttpBrokerResponse { + + private final Gson gson = new Gson(); + + @Test + public void testJsonStr() { + String jsonStr = "{\"code\":0,\"errMsg\":\"OK\",\"data\":" + + "[{\"brokerId\":136,\"brokerIp\":\"127.0.0.1\"," + + "\"brokerPort\":8123,\"manageStatus\":\"online\"," + + "\"runStatus\":\"notRegister\",\"subStatus\":\"processing_event\"," + + "\"stepOp\":32,\"isConfChanged\":\"true\",\"isConfLoaded\":\"false\"," + + "\"isBrokerOnline\":\"false\",\"brokerVersion\":\"-\"," + + "\"acceptPublish\":\"false\",\"acceptSubscribe\":\"false\"}]}"; + TubeHttpBrokerInfoList brokerInfoList = + gson.fromJson(jsonStr, TubeHttpBrokerInfoList.class); + Assert.assertEquals(1, brokerInfoList.getData().size()); + Assert.assertEquals(0, brokerInfoList.getCode()); + Assert.assertEquals("OK", brokerInfoList.getErrMsg()); + Assert.assertTrue(brokerInfoList.getData().get(0).isConfChanged()); + Assert.assertFalse(brokerInfoList.getData().get(0).isAcceptPublish()); + Assert.assertFalse(brokerInfoList.getData().get(0).isBrokerOnline()); + } +} diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpTopicInfoList.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpTopicInfoList.java new file mode 100644 index 00000000000..82a9fdad907 --- /dev/null +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/service/tube/TestTubeHttpTopicInfoList.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.service.tube; + +import com.google.gson.Gson; +import org.junit.Assert; +import org.junit.Test; + +public class TestTubeHttpTopicInfoList { + + private final Gson gson = new Gson(); + + @Test + public void testJsonStr() { + String jsonStr = "{\"result\":true,\"errCode\":0,\"errMsg\":\"OK\",\"" + + "data\":[{\"topicName\":\"test1\",\"topicInfo\":[{\"topicName\":\"test1\",\"" + + "topicStatusId\":0,\"brokerId\":152509201,\"brokerIp\":\"127.0.0.1\",\"" + + "brokerPort\":8123,\"numPartitions\":1,\"unflushThreshold\":1000,\"" + + "unflushInterval\":10000,\"unFlushDataHold\":1000,\"deleteWhen\":\"\",\"" + + "deletePolicy\":\"delete,32h\",\"acceptPublish\":true," + + "\"acceptSubscribe\":true,\"numTopicStores\":1,\"memCacheMsgSizeInMB\":2,\"" + + "memCacheFlushIntvl\":20000,\"memCacheMsgCntInK\":10," + + "\"createUser\":\"Alice\",\"createDate\":\"20200917122645\"," + + "\"modifyUser\":\"Alice\",\"modifyDate\":\"20200917122645\"," + + "\"runInfo\":{\"acceptPublish\":true,\"acceptSubscribe\":true," + + "\"numPartitions\":1,\"numTopicStores\":1," + + "\"brokerManageStatus\":\"online\"}}]}]}"; + TubeHttpTopicInfoList topicInfoList = gson.fromJson(jsonStr, TubeHttpTopicInfoList.class); + Assert.assertTrue(topicInfoList.isResult()); + Assert.assertEquals(0, topicInfoList.getErrCode()); + Assert.assertEquals(1, topicInfoList.getData().size()); + Assert.assertEquals("Alice", topicInfoList.getData().get(0) + .getTopicInfo().get(0).getCreateUser()); + Assert.assertEquals("online", topicInfoList.getData().get(0) + .getTopicInfo().get(0).getRunInfo().getBrokerManageStatus()); + } +} From 9e1ea11f5703f52dd4074fa5a60d5d6fd1b80820 Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Thu, 5 Nov 2020 10:11:09 +0800 Subject: [PATCH 4/9] [TUBEMQ-392] add query rest api for clusters (#307) --- .../apache/tubemq/manager/TubeMQManager.java | 2 +- .../controller/ManagerControllerAdvice.java | 21 ++- .../TopicResult.java => TubeResult.java} | 13 +- .../controller/cluster/ClusterController.java | 100 +++++++++++++++ .../controller/topic/TopicController.java | 23 ++-- .../controller/TestBusinessController.java | 14 +- .../controller/TestClusterController.java | 121 ++++++++++++++++++ 7 files changed, 255 insertions(+), 39 deletions(-) rename tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/{topic/TopicResult.java => TubeResult.java} (82%) create mode 100644 tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java create mode 100644 tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java index a25897beb8b..114c0bce3f8 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/TubeMQManager.java @@ -42,7 +42,7 @@ public class TubeMQManager { @Value("${manager.async.thread.prefix:AsyncThread-}") private String threadPrefix; - public static void main(String[] args) throws Exception { + public static void main(String[] args) { SpringApplication.run(TubeMQManager.class); } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java index 09a72cdcdfc..505383433df 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/ManagerControllerAdvice.java @@ -16,9 +16,6 @@ */ package org.apache.tubemq.manager.controller; -import javax.servlet.http.HttpServletRequest; -import org.apache.tubemq.manager.controller.topic.TopicResult; -import org.apache.tubemq.manager.exceptions.TubeMQManagerException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -29,18 +26,16 @@ public class ManagerControllerAdvice { /** - * handling business TubeMQManagerException, and return json format string. + * handling exception, and return json format string. * - * @param request - http request - * @param ex - exception - * @return entity + * @param ex + * @return */ - @ExceptionHandler(TubeMQManagerException.class) - public TopicResult handlingBusinessException(HttpServletRequest request, - TubeMQManagerException ex) { - TopicResult result = new TopicResult(); - result.setMessage(ex.getMessage()); - result.setCode(-1); + @ExceptionHandler(Exception.class) + public TubeResult handlingParameterException(Exception ex) { + TubeResult result = new TubeResult(); + result.setErrMsg(ex.getClass().getName() + " " + ex.getMessage()); + result.setErrCode(-1); return result; } } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicResult.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/TubeResult.java similarity index 82% rename from tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicResult.java rename to tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/TubeResult.java index 98fb81e9b63..144d9751b4d 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicResult.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/TubeResult.java @@ -14,15 +14,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.tubemq.manager.controller.topic; + +package org.apache.tubemq.manager.controller; import lombok.Data; -/** - * rest result for business controller - */ @Data -public class TopicResult { - private String message; - private int code = 0; +public class TubeResult { + private String errMsg; + private int errCode = 0; + private boolean result = true; } diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java new file mode 100644 index 00000000000..599ce15e4cf --- /dev/null +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.controller.cluster; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.tubemq.manager.service.TubeHttpConst.SCHEMA; + +import com.google.gson.Gson; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.apache.tubemq.manager.controller.TubeResult; +import org.apache.tubemq.manager.entry.NodeEntry; +import org.apache.tubemq.manager.repository.NodeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +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.RestController; + +@RestController +@RequestMapping(path = "/v1/cluster") +@Slf4j +public class ClusterController { + + private final CloseableHttpClient httpclient = HttpClients.createDefault(); + private final Gson gson = new Gson(); + + private static final String TUBE_REQUEST_PATH = "webapi.htm"; + + @Autowired + private NodeRepository nodeRepository; + + + private String covertMapToQueryString(Map requestMap) throws Exception { + List queryList = new ArrayList<>(); + + for (Map.Entry entry : requestMap.entrySet()) { + queryList.add(entry.getKey() + "=" + URLEncoder.encode( + entry.getValue(), UTF_8.toString())); + } + return StringUtils.join(queryList, "&"); + } + + private String queryMaster(String url) { + log.info("start to request {}", url); + HttpGet httpGet = new HttpGet(url); + TubeResult defaultResult = new TubeResult(); + try (CloseableHttpResponse response = httpclient.execute(httpGet)) { + // return result json to response + return EntityUtils.toString(response.getEntity()); + } catch (Exception ex) { + log.error("exception caught while requesting broker status", ex); + defaultResult.setErrCode(-1); + defaultResult.setResult(false); + defaultResult.setErrMsg(ex.getMessage()); + } + return gson.toJson(defaultResult); + } + + @RequestMapping(value = "/query", method = RequestMethod.GET, + produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String queryInfo( + @RequestParam Map queryBody) throws Exception { + int clusterId = Integer.parseInt(queryBody.get("clusterId")); + queryBody.remove("clusterId"); + NodeEntry nodeEntry = + nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(clusterId); + String url = SCHEMA + nodeEntry.getIp() + ":" + nodeEntry.getWebPort() + + "/" + TUBE_REQUEST_PATH + "?" + covertMapToQueryString(queryBody); + return queryMaster(url); + } + + +} diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java index 314d0799285..fdeac4ea505 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/topic/TopicController.java @@ -20,6 +20,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import lombok.extern.slf4j.Slf4j; +import org.apache.tubemq.manager.controller.TubeResult; import org.apache.tubemq.manager.entry.TopicEntry; import org.apache.tubemq.manager.entry.TopicStatus; import org.apache.tubemq.manager.exceptions.TubeMQManagerException; @@ -53,7 +54,7 @@ public class TopicController { * @throws Exception - exception */ @PostMapping("/add") - public TopicResult addTopic(@RequestBody TopicEntry entry) { + public TubeResult addTopic(@RequestBody TopicEntry entry) { // entry in adding status entry.setStatus(TopicStatus.ADDING.value()); topicRepository.saveAndFlush(entry); @@ -68,7 +69,7 @@ public TopicResult addTopic(@RequestBody TopicEntry entry) { } topicRepository.saveAndFlush(entry1); }); - return new TopicResult(); + return new TubeResult(); } /** @@ -78,8 +79,8 @@ public TopicResult addTopic(@RequestBody TopicEntry entry) { * @throws Exception */ @PostMapping("/update") - public TopicResult updateTopic(@RequestBody TopicEntry entry) { - return new TopicResult(); + public TubeResult updateTopic(@RequestBody TopicEntry entry) { + return new TubeResult(); } /** @@ -89,10 +90,10 @@ public TopicResult updateTopic(@RequestBody TopicEntry entry) { * @throws Exception */ @GetMapping("/check") - public TopicResult checkTopicByBusinessName( + public TubeResult checkTopicByBusinessName( @RequestParam String businessName) { List result = topicRepository.findAllByBusinessName(businessName); - return new TopicResult(); + return new TubeResult(); } /** @@ -103,13 +104,13 @@ public TopicResult checkTopicByBusinessName( * @throws Exception */ @GetMapping("/get/{id}") - public TopicResult getBusinessByID( + public TubeResult getBusinessByID( @PathVariable Long id) { Optional businessEntry = topicRepository.findById(id); - TopicResult result = new TopicResult(); + TubeResult result = new TubeResult(); if (!businessEntry.isPresent()) { - result.setCode(-1); - result.setMessage("business not found"); + result.setErrCode(-1); + result.setErrMsg("business not found"); } return result; } @@ -119,7 +120,7 @@ public TopicResult getBusinessByID( * @return */ @GetMapping("/throwException") - public TopicResult throwException() { + public TubeResult throwException() { throw new TubeMQManagerException("exception for test"); } } diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java index 0838203085c..9a497cf3f7b 100644 --- a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestBusinessController.java @@ -20,7 +20,6 @@ import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.apache.tubemq.manager.controller.topic.TopicController; -import org.apache.tubemq.manager.controller.topic.TopicResult; import org.apache.tubemq.manager.entry.TopicEntry; import org.junit.Before; import org.junit.Test; @@ -40,6 +39,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -82,8 +82,8 @@ public void testAddBusiness() throws Exception { HttpHeaders headers = new HttpHeaders(); HttpEntity request = new HttpEntity<>(entry, headers); - ResponseEntity responseEntity = - client.postForEntity(uri, request, TopicResult.class); + ResponseEntity responseEntity = + client.postForEntity(uri, request, TubeResult.class); assertThat(responseEntity.getStatusCode().is2xxSuccessful()).isEqualTo(true); } @@ -91,9 +91,9 @@ public void testAddBusiness() throws Exception { public void testControllerException() throws Exception { final String baseUrl = "http://localhost:" + randomServerPort + "/business/throwException"; URI uri = new URI(baseUrl); - ResponseEntity responseEntity = - client.getForEntity(uri, TopicResult.class); - assertThat(Objects.requireNonNull(responseEntity.getBody()).getCode()).isEqualTo(-1); - assertThat(responseEntity.getBody().getMessage()).isEqualTo("exception for test"); + ResponseEntity responseEntity = + client.getForEntity(uri, TubeResult.class); + assertThat(Objects.requireNonNull(responseEntity.getBody()).getErrCode()).isEqualTo(-1); + assertTrue(responseEntity.getBody().getErrMsg().contains("exception for test")); } } diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java new file mode 100644 index 00000000000..c31fb8d415f --- /dev/null +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java @@ -0,0 +1,121 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.tubemq.manager.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +import com.google.gson.Gson; +import lombok.extern.slf4j.Slf4j; +import org.apache.tubemq.manager.controller.cluster.ClusterController; +import org.apache.tubemq.manager.entry.NodeEntry; +import org.apache.tubemq.manager.repository.NodeRepository; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; + +@Slf4j +@RunWith(SpringRunner.class) +@SpringBootTest +@AutoConfigureMockMvc +public class TestClusterController { + + private final Gson gson = new Gson(); + + @MockBean + private NodeRepository nodeRepository; + + @InjectMocks + private ClusterController clusterController; + + @Autowired + private MockMvc mockMvc; + + private NodeEntry getNodeEntry() { + NodeEntry nodeEntry = new NodeEntry(); + nodeEntry.setMaster(true); + nodeEntry.setIp("10.215.128.83"); + nodeEntry.setWebPort(8080); + return nodeEntry; + } + + @Test + public void testExceptionQuery() throws Exception { + NodeEntry nodeEntry = getNodeEntry(); + when(nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(any(Integer.class))) + .thenReturn(nodeEntry); + RequestBuilder request = get( + "/v1/cluster/query?method=admin_query_topic_info&type=op_query"); + MvcResult result = mockMvc.perform(request).andReturn(); + String resultStr = result.getResponse().getContentAsString(); + TubeResult clusterResult = gson.fromJson(resultStr, TubeResult.class); + Assert.assertEquals(-1, clusterResult.getErrCode()); + Assert.assertTrue(clusterResult.getErrMsg().contains("NumberFormatException")); + } + + @Test + public void testTopicQuery() throws Exception { + NodeEntry nodeEntry = getNodeEntry(); + when(nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(any(Integer.class))) + .thenReturn(nodeEntry); + RequestBuilder request = get( + "/v1/cluster/query?method=admin_query_topic_info&type=op_query&clusterId=1"); + MvcResult result = mockMvc.perform(request).andReturn(); + String resultStr = result.getResponse().getContentAsString(); + log.info("result json string is {}, response type is {}", resultStr, + result.getResponse().getContentType()); + } + + @Test + public void testBrokerQuery() throws Exception { + NodeEntry nodeEntry = getNodeEntry(); + when(nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(any(Integer.class))) + .thenReturn(nodeEntry); + RequestBuilder request = get( + "/v1/cluster/query?method=admin_query_broker_run_status&type=op_query&clusterId=1&brokerIp="); + MvcResult result = mockMvc.perform(request).andReturn(); + String resultStr = result.getResponse().getContentAsString(); + log.info("result json string is {}, response type is {}", resultStr, + result.getResponse().getContentType()); + } + + @Test + public void testTopicAndGroupQuery() throws Exception { + NodeEntry nodeEntry = getNodeEntry(); + when(nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(any(Integer.class))) + .thenReturn(nodeEntry); + RequestBuilder request = get( + "/v1/cluster/query?method=admin_query_sub_info&type=op_query&clusterId=1&topicName=test&groupName=test"); + MvcResult result = mockMvc.perform(request).andReturn(); + String resultStr = result.getResponse().getContentAsString(); + log.info("result json string is {}, response type is {}", resultStr, + result.getResponse().getContentType()); + } + + +} From 4cf9e9aabe544dcef787e02e5511a7aea044883d Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Thu, 5 Nov 2020 19:38:01 +0800 Subject: [PATCH 5/9] [TUBEMQ-402] add modify rest api for clusters (#308) --- .../controller/cluster/ClusterController.java | 28 ++++++++++++++++ .../controller/TestClusterController.java | 33 +++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java index 599ce15e4cf..58fe8f9bf46 100644 --- a/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java +++ b/tubemq-manager/src/main/java/org/apache/tubemq/manager/controller/cluster/ClusterController.java @@ -37,6 +37,7 @@ import org.apache.tubemq.manager.repository.NodeRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +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; @@ -96,5 +97,32 @@ private String queryMaster(String url) { return queryMaster(url); } + /** + * modify cluster info, need to check token and + * make sure user has authorization to modify it. + */ + @RequestMapping(value = "/modify", method = RequestMethod.POST, + produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String modifyClusterInfo( + @RequestBody Map requestBody) throws Exception { + String token = requestBody.get("confModAuthToken"); + log.info("token is {}, request body size is {}", token, requestBody.keySet()); + int clusterId = Integer.parseInt(requestBody.get("clusterId")); + if (StringUtils.isNotBlank(token)) { + requestBody.remove("clusterId"); + NodeEntry nodeEntry = nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue( + clusterId); + String url = SCHEMA + nodeEntry.getIp() + ":" + nodeEntry.getWebPort() + + "/" + TUBE_REQUEST_PATH + "?" + covertMapToQueryString(requestBody); + return queryMaster(url); + } else { + TubeResult result = new TubeResult(); + result.setErrCode(-1); + result.setResult(false); + result.setErrMsg("token is not correct"); + return gson.toJson(result); + } + } + } diff --git a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java index c31fb8d415f..efd1cb11c35 100644 --- a/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java +++ b/tubemq-manager/src/test/java/org/apache/tubemq/manager/controller/TestClusterController.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import com.google.gson.Gson; import lombok.extern.slf4j.Slf4j; @@ -34,6 +35,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -59,7 +61,7 @@ public class TestClusterController { private NodeEntry getNodeEntry() { NodeEntry nodeEntry = new NodeEntry(); nodeEntry.setMaster(true); - nodeEntry.setIp("10.215.128.83"); + nodeEntry.setIp("127.0.0.1"); nodeEntry.setWebPort(8080); return nodeEntry; } @@ -117,5 +119,32 @@ public void testTopicAndGroupQuery() throws Exception { result.getResponse().getContentType()); } - + @Test + public void testTopicAdd() throws Exception { + String jsonStr = "{\n" + + " \"type\": \"op_modify\",\n" + + " \"method\": \"admin_add_new_topic_record\",\n" + + " \"confModAuthToken\": \"test\",\n" + + " \"clusterId\": 1,\n" + + " \"createUser\": \"webapi\",\n" + + " \"topicName\": \"test\",\n" + + " \"deleteWhen\": \"0 0 0 0 0\",\n" + + " \"unflushThreshold\": 1000,\n" + + " \"acceptPublish\": true,\n" + + " \"numPartitions\": 3,\n" + + " \"deletePolicy\": \"\",\n" + + " \"unflushInterval\": 1000,\n" + + " \"acceptSubscribe\": true,\n" + + " \"brokerId\": 12323\n" + + "}\n"; + NodeEntry nodeEntry = getNodeEntry(); + when(nodeRepository.findNodeEntryByClusterIdIsAndMasterIsTrue(any(Integer.class))) + .thenReturn(nodeEntry); + RequestBuilder request = post("/v1/cluster/modify") + .contentType(MediaType.APPLICATION_JSON).content(jsonStr); + MvcResult result = mockMvc.perform(request).andReturn(); + String resultStr = result.getResponse().getContentAsString(); + log.info("result json string is {}, response type is {}", resultStr, + result.getResponse().getContentType()); + } } From 9dfa8ad2fc05be302813180c8d0b7837e48e46f1 Mon Sep 17 00:00:00 2001 From: zakwu <123537200@qq.com> Date: Fri, 6 Nov 2020 14:49:05 +0800 Subject: [PATCH 6/9] feat(webclient): rebuild web client --- web/.env | 2 + web/.eslintignore | 5 + web/.eslintrc | 28 + web/.gitignore | 26 + web/.prettierrc | 4 + web/.stylelintrc | 3 + web/README.md | 33 ++ web/config-overrides.js | 52 ++ web/mock/_constant.js | 3 + web/mock/app.js | 6 + web/package.json | 110 ++++ web/public/favicon.ico | Bin 0 -> 1226 bytes web/public/index.html | 43 ++ web/public/logo192.png | Bin 0 -> 33077 bytes web/public/logo512.png | Bin 0 -> 9664 bytes web/public/manifest.json | 24 + web/public/robots.txt | 3 + web/src/components/Breadcrumb/index.less | 8 + web/src/components/Breadcrumb/index.tsx | 47 ++ web/src/components/Layout/index.less | 31 ++ web/src/components/Layout/index.tsx | 76 +++ web/src/components/Modalx/index.less | 17 + web/src/components/Modalx/index.tsx | 49 ++ web/src/components/Tablex/index.less | 16 + web/src/components/Tablex/index.tsx | 114 ++++ .../components/Tablex/tableFilterHelper.ts | 32 ++ web/src/components/TitleWrap/index.less | 11 + web/src/components/TitleWrap/index.tsx | 22 + web/src/components/index.tsx | 3 + web/src/configs/index.ts | 3 + web/src/configs/menus/index.tsx | 50 ++ web/src/constants/broker.ts | 22 + web/src/constants/person.ts | 4 + web/src/constants/topic.ts | 10 + web/src/context/globalContext.ts | 12 + web/src/defaultSettings.js | 6 + web/src/hooks/index.ts | 52 ++ web/src/index.tsx | 11 + web/src/pages/Broker/commonModal.tsx | 274 ++++++++++ web/src/pages/Broker/detail.tsx | 378 +++++++++++++ web/src/pages/Broker/index.less | 9 + web/src/pages/Broker/index.tsx | 280 ++++++++++ web/src/pages/Broker/query.tsx | 128 +++++ web/src/pages/Cluster/index.less | 0 web/src/pages/Cluster/index.tsx | 143 +++++ web/src/pages/Issue/consumeGroupDetail.tsx | 95 ++++ web/src/pages/Issue/index.less | 0 web/src/pages/Issue/index.tsx | 98 ++++ web/src/pages/NotFound/index.tsx | 5 + web/src/pages/Topic/commonModal.tsx | 349 ++++++++++++ web/src/pages/Topic/detail.tsx | 510 ++++++++++++++++++ web/src/pages/Topic/index.less | 9 + web/src/pages/Topic/index.tsx | 279 ++++++++++ web/src/pages/Topic/query.tsx | 180 +++++++ web/src/react-app-env.d.ts | 1 + web/src/router.tsx | 55 ++ web/src/routes/index.tsx | 37 ++ web/src/serviceWorker.ts | 146 +++++ web/src/setupProxy.js | 12 + web/src/store/global.ts | 30 ++ web/src/typings/index.ts | 1 + web/src/typings/router.ts | 14 + web/src/utils/index.ts | 45 ++ web/tsconfig.json | 33 ++ web/tsconfig.paths.json | 8 + 65 files changed, 4057 insertions(+) create mode 100644 web/.env create mode 100644 web/.eslintignore create mode 100644 web/.eslintrc create mode 100644 web/.gitignore create mode 100644 web/.prettierrc create mode 100644 web/.stylelintrc create mode 100644 web/README.md create mode 100644 web/config-overrides.js create mode 100644 web/mock/_constant.js create mode 100644 web/mock/app.js create mode 100644 web/package.json create mode 100644 web/public/favicon.ico create mode 100644 web/public/index.html create mode 100644 web/public/logo192.png create mode 100644 web/public/logo512.png create mode 100644 web/public/manifest.json create mode 100644 web/public/robots.txt create mode 100644 web/src/components/Breadcrumb/index.less create mode 100644 web/src/components/Breadcrumb/index.tsx create mode 100644 web/src/components/Layout/index.less create mode 100644 web/src/components/Layout/index.tsx create mode 100644 web/src/components/Modalx/index.less create mode 100644 web/src/components/Modalx/index.tsx create mode 100644 web/src/components/Tablex/index.less create mode 100644 web/src/components/Tablex/index.tsx create mode 100644 web/src/components/Tablex/tableFilterHelper.ts create mode 100644 web/src/components/TitleWrap/index.less create mode 100644 web/src/components/TitleWrap/index.tsx create mode 100644 web/src/components/index.tsx create mode 100644 web/src/configs/index.ts create mode 100644 web/src/configs/menus/index.tsx create mode 100644 web/src/constants/broker.ts create mode 100644 web/src/constants/person.ts create mode 100644 web/src/constants/topic.ts create mode 100644 web/src/context/globalContext.ts create mode 100644 web/src/defaultSettings.js create mode 100644 web/src/hooks/index.ts create mode 100644 web/src/index.tsx create mode 100644 web/src/pages/Broker/commonModal.tsx create mode 100644 web/src/pages/Broker/detail.tsx create mode 100644 web/src/pages/Broker/index.less create mode 100644 web/src/pages/Broker/index.tsx create mode 100644 web/src/pages/Broker/query.tsx create mode 100644 web/src/pages/Cluster/index.less create mode 100644 web/src/pages/Cluster/index.tsx create mode 100644 web/src/pages/Issue/consumeGroupDetail.tsx create mode 100644 web/src/pages/Issue/index.less create mode 100644 web/src/pages/Issue/index.tsx create mode 100644 web/src/pages/NotFound/index.tsx create mode 100644 web/src/pages/Topic/commonModal.tsx create mode 100644 web/src/pages/Topic/detail.tsx create mode 100644 web/src/pages/Topic/index.less create mode 100644 web/src/pages/Topic/index.tsx create mode 100644 web/src/pages/Topic/query.tsx create mode 100644 web/src/react-app-env.d.ts create mode 100644 web/src/router.tsx create mode 100644 web/src/routes/index.tsx create mode 100644 web/src/serviceWorker.ts create mode 100644 web/src/setupProxy.js create mode 100644 web/src/store/global.ts create mode 100644 web/src/typings/index.ts create mode 100644 web/src/typings/router.ts create mode 100644 web/src/utils/index.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.paths.json diff --git a/web/.env b/web/.env new file mode 100644 index 00000000000..936a9dc68d0 --- /dev/null +++ b/web/.env @@ -0,0 +1,2 @@ +PORT=3000 +SKIP_PREFLIGHT_CHECK=true diff --git a/web/.eslintignore b/web/.eslintignore new file mode 100644 index 00000000000..6bba8e49952 --- /dev/null +++ b/web/.eslintignore @@ -0,0 +1,5 @@ +node_modules/** +dist/** +public/** +src/serviceWorker.ts +mock diff --git a/web/.eslintrc b/web/.eslintrc new file mode 100644 index 00000000000..472dd8defb9 --- /dev/null +++ b/web/.eslintrc @@ -0,0 +1,28 @@ +{ + "extends": [ + "plugin:@typescript-eslint/recommended", + "react-app", + "plugin:prettier/recommended" + ], + "plugins": ["@typescript-eslint", "react", "prettier"], + "env": { + "browser": true, + "node": true, + "mocha": true + }, + "globals": { + "Babel": true, + "React": true + }, + "settings": { + "react": { + "version": "detect" + } + }, + "rules": { + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "jsx-a11y/anchor-is-valid": 0, + "react-hooks/exhaustive-deps": 0 + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000000..448f7e5647b --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +package-lock.json +npm-debug.log* +yarn-debug.log* +yarn-error.log* +yarn.lock +.history diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 00000000000..c1a6f667131 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/web/.stylelintrc b/web/.stylelintrc new file mode 100644 index 00000000000..2e8ff5864a4 --- /dev/null +++ b/web/.stylelintrc @@ -0,0 +1,3 @@ +{ + "extends": ["stylelint-config-standard", "stylelint-config-prettier"] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000000..d96c9e51096 --- /dev/null +++ b/web/README.md @@ -0,0 +1,33 @@ +# web +This project was bootstrapped with [React Seed](https://github.com/reactseed/reactseed). + +## Available Scripts +Inside the newly created project, you can run some built-in commands: + +### `npm start` or `yarn start` + +Runs the app in development mode. +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits. +You will also see any lint errors in the console. + +### `npm test` or `yarn test` + +Launches the test runner in the interactive watch mode. +See the section about [running tests](https://create-react-app.dev/docs/running-tests/) for more information. + +### `npm run build` or `yarn build` + +Builds the app for production to the build folder. +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes. +Your app is ready to be deployed! + +See the section about [deployment](https://create-react-app.dev/docs/deployment/) for more information. + +### `npm run analyze` or `yarn analyze` + +Analyzes JavaScript bundles using the source maps. +> You need to run `npm run build` or `yarn build` before analysis. diff --git a/web/config-overrides.js b/web/config-overrides.js new file mode 100644 index 00000000000..bb8515bcdec --- /dev/null +++ b/web/config-overrides.js @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require('path'); +const webpack = require('webpack'); +const devServer = require('@reactseed/devserver'); +const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin'); +const { + override, + addWebpackAlias, + addLessLoader, + overrideDevServer, + addWebpackPlugin, + fixBabelImports, + addBabelPlugin, +} = require('customize-cra'); +const nodeModulesPath = path.resolve(__dirname, 'node_modules'); +const nodeModules = pkg => path.resolve(nodeModulesPath, pkg); + +module.exports = { + webpack: override( + addBabelPlugin('react-hot-loader/babel'), + addLessLoader({ + javascriptEnabled: true, + }), + addWebpackAlias({ + '@': path.resolve(__dirname, 'src'), + }), + fixBabelImports('antd', { + libraryDirectory: 'lib', + style: 'css', + }), + addWebpackPlugin( + new AntdDayjsWebpackPlugin(), + new webpack.HotModuleReplacementPlugin() + ), + config => { + if (config.mode === 'development') { + config.resolve.alias['react-dom'] = path.resolve( + __dirname, + 'node_modules/@hot-loader/react-dom' + ); + config.entry.unshift(nodeModules('react-hot-loader/patch')); + } + return config; + } + ), + devServer: overrideDevServer(devServer, config => { + config.inline = true; + // eslint-disable-next-line no-undef + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000); + return config; + }), +}; diff --git a/web/mock/_constant.js b/web/mock/_constant.js new file mode 100644 index 00000000000..286afb497d0 --- /dev/null +++ b/web/mock/_constant.js @@ -0,0 +1,3 @@ +module.exports = { + apiPrefix: '/api', +}; diff --git a/web/mock/app.js b/web/mock/app.js new file mode 100644 index 00000000000..6a109a8c98d --- /dev/null +++ b/web/mock/app.js @@ -0,0 +1,6 @@ +const { apiPrefix } = require('./_constant'); +const packageJSON = require('../package.json'); + +module.exports = { + [`GET ${apiPrefix}/app`]: packageJSON, +}; diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000000..19ecc9309f0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,110 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "dependencies": { + "@ant-design/icons": "^4.2.1", + "@ant-design/pro-layout": "^5.0.12", + "@reactseed/use-redux": "^0.0.3", + "@reactseed/use-request": "^0.0.2", + "@types/lodash": "^4.14.155", + "@umijs/use-request": "^1.4.3", + "antd": "^4.2.2", + "immer": "^6.0.9", + "lodash": "^4.17.15", + "react": "16.13.0", + "react-dom": "16.13.0", + "react-redux": "^7.2.0", + "react-router-dom": "^5.2.0", + "redux": "^4.0.5" + }, + "scripts": { + "analyze": "source-map-explorer 'build/static/js/*.js'", + "start": "EXTEND_ESLINT=true react-app-rewired start", + "build": "react-app-rewired build", + "test": "react-app-rewired test", + "commit": "git cz", + "prettier": "prettier --write 'src/**/*.{ts,tsx}'", + "eslint": "eslint --fix --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}'", + "stylelint": "stylelint 'src/**/*.less' --syntax less --allow-empty-input" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "lint-staged": { + "*.{ts,tsx}": [ + "npm run eslint", + "npm run prettier" + ], + "*.{less}": [ + "npm run stylelint" + ] + }, + "pre-commit": "lint-staged", + "husky": { + "hooks": { + "pre-commit": "lint-staged", + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS --verbose --no-verify" + } + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "devDependencies": { + "@commitlint/cli": "^8.3.5", + "@commitlint/config-conventional": "^8.3.4", + "@hot-loader/react-dom": "=16.13.0", + "@reactseed/devserver": "^0.0.2", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/jest": "^25.2.1", + "@types/node": "^13.13.5", + "@types/react": "^16.9.35", + "@types/react-dom": "^16.9.8", + "@types/react-redux": "^7.1.9", + "@types/react-router-dom": "^5.1.5", + "antd-dayjs-webpack-plugin": "^1.0.0", + "babel-plugin-import": "^1.13.0", + "customize-cra": "^0.9.1", + "cz-conventional-changelog": "^3.2.0", + "eslint": "^6.8.0", + "eslint-config-prettier": "^6.10.0", + "eslint-config-react-app": "^5.2.0", + "eslint-plugin-prettier": "^3.1.2", + "eslint-plugin-react": "^7.19.0", + "eslint-plugin-react-hooks": "^4.0.2", + "http-proxy-middleware": "^1.0.1", + "husky": "^4.2.5", + "less": "^3.11.1", + "less-loader": "^5.0.0", + "lint-staged": "^10.2.2", + "prettier": "^1.19.1", + "react-app-rewire-hot-loader": "^2.0.1", + "react-app-rewired": "^2.1.5", + "react-hot-loader": "^4.12.21", + "react-scripts": "3.4.0", + "source-map-explorer": "^2.3.1", + "stylelint": "^13.3.3", + "stylelint-config-prettier": "^8.0.1", + "stylelint-config-standard": "^20.0.0", + "typescript": "~3.8.3", + "use-immer": "^0.4.0" + } +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..094e7d64f92c35af45144515db25dd1e64a9a21c GIT binary patch literal 1226 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t(uO^eJ7i71Ki^|4CM&(%vz$xlkv ztH>0+>{umUo3Q%e#RDspr3imfVamB1>jfNYSkzLEl1NlCV?QiN}Sf^&XRs)DJW ziJpOy9hZWFf=y9MnpKdC8&o@xXRDM^Qc_^0uU}qXu2*iXmtT~wZ)j<0sc&GUZ)Btk zRH0j3nOBlnp_^B%3^4>|j!SBBa#3bMNoIbY0?6FNr2NtnTO}osMQ{LdXGvxn!lt}p zsJDO~)CbAv8|oS8!_5Y2wE>A*`4?rT0&NDFZ)a!&R*518wZ}#uWI2*!AU*|)0=;U- zWup%dHajk#L+X(X3{2jhE{-7c*&>bt>sGz1Oq;yJP|=WUH)B56qVR*xUtYL= zcy)5V!o1Btng6laRvK1)ObW2!;Od&bz@j`{NC z(66n%DmpeAyQ` zM{I4b(5E)l=(o?Nlt|h}mfzQXx90kt4%IY|qISN8iIHc58kIMH6#BA5$M{?jb0g!u zlE$r5mC{c9%zmrgoq5Qop4a;HdmEPl1M5tKtcUHx3vIVCg!0BGi}0ssI2 literal 0 HcmV?d00001 diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 00000000000..b833b691855 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + web + + + +

+ + + diff --git a/web/public/logo192.png b/web/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..904a23585e6cf69ef374e7bca067c798d991bdbe GIT binary patch literal 33077 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 literal 0 HcmV?d00001 diff --git a/web/public/logo512.png b/web/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..a4e47a6545bc15971f8f63fba70e4013df88a664 GIT binary patch literal 9664 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 00000000000..4e45728b476 --- /dev/null +++ b/web/public/manifest.json @@ -0,0 +1,24 @@ +{ + "short_name": "web", + "name": "web", + "icons": [{ + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 00000000000..e9e57dc4d41 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/web/src/components/Breadcrumb/index.less b/web/src/components/Breadcrumb/index.less new file mode 100644 index 00000000000..f02c2b7f238 --- /dev/null +++ b/web/src/components/Breadcrumb/index.less @@ -0,0 +1,8 @@ +.breadcrumb-wrapper { + background: #fff; + padding: 10px 20px; +} + +.ant-pro-basicLayout-content { + margin: 1px 0 0 0; +} \ No newline at end of file diff --git a/web/src/components/Breadcrumb/index.tsx b/web/src/components/Breadcrumb/index.tsx new file mode 100644 index 00000000000..c4113bf6f91 --- /dev/null +++ b/web/src/components/Breadcrumb/index.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { MenuDataItem } from '@ant-design/pro-layout'; +import { Link } from 'react-router-dom'; +import { useLocation } from '@/hooks'; +import './index.less'; +import { Breadcrumb } from 'antd'; + +export interface BreadcrumbProps { + breadcrumbMap?: Map; + appendParams?: string; +} + +const BasicLayout: React.FC = props => { + const location = useLocation(); + const { breadcrumbMap, appendParams } = props; + + const pathSnippets = location.pathname.split('/').filter(i => i); + const breadcrumbItems = pathSnippets.map((_, index) => { + const breadcrumbNameMap = {} as any; + breadcrumbMap && + breadcrumbMap.forEach((t: MenuDataItem) => { + breadcrumbNameMap[t.key as string] = t.name; + }); + const url = `/${pathSnippets.slice(0, index + 1).join('/')}`; + if (appendParams && index === pathSnippets.length - 1) { + return ( + + {appendParams} + + ); + } + + return ( + + {breadcrumbNameMap[url]} + + ); + }); + + return ( + <> + {breadcrumbItems} + + ); +}; + +export default BasicLayout; diff --git a/web/src/components/Layout/index.less b/web/src/components/Layout/index.less new file mode 100644 index 00000000000..6e0ed86a967 --- /dev/null +++ b/web/src/components/Layout/index.less @@ -0,0 +1,31 @@ +#root { + height: 100%; +} + +.header-wrapper { + width: 400px; +} + +.header-span { + float: left; +} + +// global css +.main-container { + background: #fff; + padding: 10px 20px 20px 20px; +} + +.search-wrapper { + margin-bottom: 20px; +} + +.options-wrapper { + a { + margin-right: 8px; + } +} + +.ant-layout-sider-collapsed .ant-pro-sider-menu-logo { + margin-left: -15px; +} \ No newline at end of file diff --git a/web/src/components/Layout/index.tsx b/web/src/components/Layout/index.tsx new file mode 100644 index 00000000000..a4a4ddd01f0 --- /dev/null +++ b/web/src/components/Layout/index.tsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect, useMemo, useContext } from 'react'; +import ProBasicLayout, { + SettingDrawer, + getMenuData, + MenuDataItem, + SettingDrawerProps, +} from '@ant-design/pro-layout'; +import { Link } from 'react-router-dom'; +import { useLocation } from '@/hooks'; +import { isDevelopEnv } from '@/utils'; +import initSetting from '@/defaultSettings'; +import { menus } from '@/configs'; +import './index.less'; +import GlobalContext from '@/context/globalContext'; + +const BasicLayout: React.FC = props => { + const { cluster, setBreadMap } = useContext(GlobalContext); + const location = useLocation(); + const [settings, setSetting] = useState( + initSetting as SettingDrawerProps['settings'] + ); + const [openKeys, setOpenKeys] = useState([]); + const [selectedKeys, setSelectedKeys] = useState(['/']); + const isDev = isDevelopEnv(); + const { pathname } = location; + const { breadcrumbMap, menuData } = useMemo(() => getMenuData(menus), []); + // set breadmap 4 children page 2 use + setBreadMap && setBreadMap(breadcrumbMap); + useEffect(() => { + const select = breadcrumbMap.get(pathname); + if (select) { + setOpenKeys((select as MenuDataItem)['pro_layout_parentKeys']); + setSelectedKeys([(select as MenuDataItem)['key'] as string]); + } + }, [breadcrumbMap, pathname]); + + return ( + <> + menuData} + menuItemRender={(menuItemProps, defaultDom) => { + if (menuItemProps.isUrl || !menuItemProps.path) { + return defaultDom; + } + return {defaultDom}; + }} + headerRender={(menuItemProps, defaultDom) => ( +
+ {defaultDom} + {cluster} +
+ )} + menuProps={{ + selectedKeys, + openKeys, + onOpenChange: setOpenKeys, + }} + {...settings} + > + {props.children} +
+ + {isDev && ( + document.getElementById('root')} + settings={settings} + onSettingChange={setSetting} + /> + )} + + ); +}; + +export default BasicLayout; diff --git a/web/src/components/Modalx/index.less b/web/src/components/Modalx/index.less new file mode 100644 index 00000000000..6b03518b14d --- /dev/null +++ b/web/src/components/Modalx/index.less @@ -0,0 +1,17 @@ +.psw-set { + border-top: 1px solid #ccc; + padding-top: 20px; + + .pws-label { + float: left; + line-height: 32px; + } + + .psw-input { + width: 300px; + } +} + +.enhance { + color: red; +} \ No newline at end of file diff --git a/web/src/components/Modalx/index.tsx b/web/src/components/Modalx/index.tsx new file mode 100644 index 00000000000..6d42e8259a4 --- /dev/null +++ b/web/src/components/Modalx/index.tsx @@ -0,0 +1,49 @@ +/** + * TABLE COMPONENT WITH SEARCH + */ +import { Modal, Input } from 'antd'; +import * as React from 'react'; +import { ModalProps } from 'antd/lib/modal'; +import { ReactElement } from 'react'; +import './index.less'; + +const { useState } = React; + +export interface OKProps { + e: React.MouseEvent; + psw: string; + params?: any; +} + +type ComProps = { + context?: number; + children?: ReactElement; + onOk?: (p: OKProps) => {}; + params?: any; +}; + +const Comp = (props: ComProps & Omit) => { + const { params } = props; + const [psw, setPsw] = useState(''); + const onOk = (e: React.MouseEvent) => { + props.onOk && props.onOk({ e, psw, params }); + }; + + return ( + <> + + {props.children} +
+ + setPsw(e.target.value)} + /> +
+
+ + ); +}; + +export default Comp; diff --git a/web/src/components/Tablex/index.less b/web/src/components/Tablex/index.less new file mode 100644 index 00000000000..f49a245a743 --- /dev/null +++ b/web/src/components/Tablex/index.less @@ -0,0 +1,16 @@ +.textWrap { + white-space: pre-line; + word-break: break-word; +} +.pb10{ + padding-bottom: 10px; +} +.pd10{ + padding: 10px; +} +.mt10{ + margin-top: 10px; +} +.mb10{ + margin-bottom: 10px; +} diff --git a/web/src/components/Tablex/index.tsx b/web/src/components/Tablex/index.tsx new file mode 100644 index 00000000000..ec412776606 --- /dev/null +++ b/web/src/components/Tablex/index.tsx @@ -0,0 +1,114 @@ +/** + * TABLE COMPONENT WITH SEARCH + */ +import { Table, Input, Row, Button, Col, Tooltip } from 'antd'; +import * as React from 'react'; +import { TableProps } from 'antd/lib/table'; +import { CaretDownFilled, CaretUpFilled } from '@ant-design/icons'; +import { isEmptyParam } from '@/utils'; +import { useEffect } from 'react'; +import './index.less'; + +const { useState } = React; + +const { Search } = Input; + +interface ComProps extends TableProps { + filterFnX?: (value: any) => void; + columns?: any; + dataSourceX?: any; + searchPlaceholder?: string; + defaultSearchKey?: string; + isTruePagination?: boolean; + showSearch?: boolean; + searchWidth?: number; + searchStyle?: any; +} + +const Comp = (props: ComProps) => { + const { + columns, + filterFnX, + searchPlaceholder, + expandable, + defaultSearchKey, + isTruePagination, + showSearch = true, + searchWidth = 8, + searchStyle = {}, + } = props; + const [filterKey, setFilterKey] = useState(defaultSearchKey); + // 自动增加排序 + if (columns) { + columns.forEach((t: any) => { + t.sorter = (a: any, b: any) => + a[(t as any).dataIndex] - b[(t as any).dataIndex] >= 0 ? 1 : -1; + }); + } + if (expandable && !expandable.expandIcon) { + expandable.expandIcon = ({ expanded, onExpand, record }) => + expanded ? ( + onExpand(record, e)} /> + ) : ( + onExpand(record, e)} /> + ); + } + const opts = { ...props }; + const dataSource = + (!filterKey && isEmptyParam(opts.dataSourceX)) || isTruePagination + ? opts.dataSource + : opts.dataSourceX; + // 如有默认,先处理一次 + useEffect(() => { + if (defaultSearchKey === undefined) return; + + setFilterKey(defaultSearchKey || ''); + filterFnX && filterFnX(defaultSearchKey || ''); + }, [defaultSearchKey, filterFnX]); + // 分页如果只有一页,自动隐藏 + opts.pagination = Object.assign( + { + hideOnSinglePage: true, + }, + { + ...opts.pagination, + } + ); + + const onChange = (e: any) => { + if (!isTruePagination) { + filterFnX && filterFnX(e.target.value); + } + + setFilterKey(e.target.value); + }; + + return ( + <> + {showSearch && filterFnX && ( + + + + filterFnX(v)} + allowClear + placeholder={searchPlaceholder || '字符串大小写敏感'} + enterButton={ + + } + /> + + + + )} + + + + ); +}; + +export default Comp; diff --git a/web/src/components/Tablex/tableFilterHelper.ts b/web/src/components/Tablex/tableFilterHelper.ts new file mode 100644 index 00000000000..0032e8fec30 --- /dev/null +++ b/web/src/components/Tablex/tableFilterHelper.ts @@ -0,0 +1,32 @@ +interface TableFilterHelperProp { + key: string; + targetArray: Array; + srcArray: Array; + filterList: Array; + updateFunction?: (p: Array) => void; +} +const tableFilterHelper = (p: TableFilterHelperProp): any[] => { + const { key, srcArray = [], filterList, updateFunction } = p; + const res: any[] = []; + + if (key) { + srcArray.forEach(it => { + const tar = filterList.map(t => { + return it[t]; + }); + let isFilterRight = false; + tar.forEach(t => { + if ((t + '').indexOf(key) > -1) isFilterRight = true; + }); + if (isFilterRight) { + res.push(it); + } + }); + } + + if (updateFunction) updateFunction(res); + + return res; +}; + +export default tableFilterHelper; diff --git a/web/src/components/TitleWrap/index.less b/web/src/components/TitleWrap/index.less new file mode 100644 index 00000000000..b64cf1bae86 --- /dev/null +++ b/web/src/components/TitleWrap/index.less @@ -0,0 +1,11 @@ +.title-wrap-title { + font-size: 16px; + font-weight: bold; + margin-top: 15px; + margin-bottom: 15px; + position: relative; +} + +.split-border { + border-top: 1px solid #eee; +} diff --git a/web/src/components/TitleWrap/index.tsx b/web/src/components/TitleWrap/index.tsx new file mode 100644 index 00000000000..47624f4c5b8 --- /dev/null +++ b/web/src/components/TitleWrap/index.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import './index.less'; + +interface ComProps { + title: any; + children?: any; + wrapperStyle?: any; + hasSplit?: boolean; +} + +const Comp = (props: ComProps) => { + const { hasSplit = true } = props; + + return ( +
+
{props.title}
+ {props.children} +
+ ); +}; + +export default Comp; diff --git a/web/src/components/index.tsx b/web/src/components/index.tsx new file mode 100644 index 00000000000..d7314f62b9f --- /dev/null +++ b/web/src/components/index.tsx @@ -0,0 +1,3 @@ +import Layout from './Layout'; + +export { Layout }; diff --git a/web/src/configs/index.ts b/web/src/configs/index.ts new file mode 100644 index 00000000000..1cb36a691e5 --- /dev/null +++ b/web/src/configs/index.ts @@ -0,0 +1,3 @@ +import menus from './menus'; + +export { menus }; diff --git a/web/src/configs/menus/index.tsx b/web/src/configs/menus/index.tsx new file mode 100644 index 00000000000..e689571e6fe --- /dev/null +++ b/web/src/configs/menus/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Route } from '@/typings/router'; +import { + NodeExpandOutlined, + ClusterOutlined, + SettingOutlined, +} from '@ant-design/icons'; +/* + * Note: + * Menu items with children need to set a key starting with "/" + * @see https://github.com/umijs/route-utils/blob/master/src/transformRoute/transformRoute.ts#L219 + */ + +const menus: Route[] = [ + { + path: '/issue', + name: '分发查询', + icon: , + hideChildrenInMenu: true, + children: [ + { + path: '/:id', + name: '消费组详情', + }, + ], + }, + { + name: '配置管理', + key: '/other', + icon: , + path: '/other', + children: [ + { + path: '/broker', + name: 'Broker列表', + }, + { + path: '/topic', + name: 'topic列表', + }, + ], + }, + { + path: '/cluster', + name: '集群管理', + icon: , + }, +]; + +export default menus; diff --git a/web/src/constants/broker.ts b/web/src/constants/broker.ts new file mode 100644 index 00000000000..bc6ac45ab04 --- /dev/null +++ b/web/src/constants/broker.ts @@ -0,0 +1,22 @@ +export const BROKER_INFO_ZH_MAP = { + acceptPublish: '可发布', + acceptSubscribe: '可订阅', + brokerId: 'BrokerId', + brokerIp: 'BrokerIP', + brokerPort: 'BrokerPort', + brokerTLSPort: 'TLS端口', + brokerVersion: '版本', + enableTLS: '启用TLS', + isAutoForbidden: '自动屏蔽', + isBrokerOnline: 'broker注册', + isConfChanged: '配置变更', + isConfLoaded: '变更加载', + isRepAbnormal: '上报异常', + manageStatus: '管理状态', + runStatus: '运行状态', + subStatus: '运行子状态', + 'runInfo.acceptPublish': 'broker可发布状态', + 'runInfo.acceptSubscribe': 'broker可订阅状态', + 'runInfo.numPartitions': 'broker分区数', + 'runInfo.brokerManageStatus': 'broker运行状态', +}; diff --git a/web/src/constants/person.ts b/web/src/constants/person.ts new file mode 100644 index 00000000000..eadf34f0528 --- /dev/null +++ b/web/src/constants/person.ts @@ -0,0 +1,4 @@ +export const PERSON_INFO_ZH_MAP = { + createDate: '创建时间', + createUser: '创建人', +}; diff --git a/web/src/constants/topic.ts b/web/src/constants/topic.ts new file mode 100644 index 00000000000..d4c8abf69b3 --- /dev/null +++ b/web/src/constants/topic.ts @@ -0,0 +1,10 @@ +export const TOPIC_INFO_ZH_MAP = { + topicName: 'TopicName', + infoCount: '配置Broker数', + totalCfgNumPart: '配置分区数', + totalRunNumPartCount: '运行分区数', + isSrvAcceptPublish: '可发布', + isSrvAcceptSubscribe: '可订阅', + enableAuthControl: '权限受控', + groupCount: '授权消费组', +}; diff --git a/web/src/context/globalContext.ts b/web/src/context/globalContext.ts new file mode 100644 index 00000000000..0540e02fd8f --- /dev/null +++ b/web/src/context/globalContext.ts @@ -0,0 +1,12 @@ +// global context +import React from 'react'; +import { BreadcrumbProps } from '@/components/Breadcrumb'; +export interface GlobalContextProps { + cluster?: string; + setCluster?: Function; + breadMap?: BreadcrumbProps['breadcrumbMap']; + setBreadMap?: Function; + userInfo?: any; +} + +export default React.createContext({}); diff --git a/web/src/defaultSettings.js b/web/src/defaultSettings.js new file mode 100644 index 00000000000..684a9a36ea3 --- /dev/null +++ b/web/src/defaultSettings.js @@ -0,0 +1,6 @@ +export default { + layout: 'sidemenu', + contentWidth: 'Fluid', + navTheme: 'dark', + primaryColor: '#1890ff', +}; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts new file mode 100644 index 00000000000..8340c9e01d7 --- /dev/null +++ b/web/src/hooks/index.ts @@ -0,0 +1,52 @@ +import { useHistory, useLocation } from 'react-router-dom'; +import useRequest, { axios } from '@reactseed/use-request'; +import useRedux from '@reactseed/use-redux'; +import { message } from 'antd'; + +interface DataProps { + data: any; + errorCode: number; + errMsg: number; + result: boolean; +} +// handler for old type interface +axios.interceptors.request.use( + config => { + const urlArr = (config.url as any).split('/'); + config.url = '/webapi.htm'; + config.params = config.params || {}; + config.params['type'] = urlArr[2]; + config.params['method'] = urlArr[3]; + + return config; + }, + function(error) { + return Promise.reject(error); + } +); + +axios.interceptors.response.use( + ({ data }) => { + if (data.errCode !== 0) { + message.error(data.errMsg); + return Promise.reject(data); + } + + // admin_query_master_group_info interface design no good need handle + if ( + Object.keys(data).includes('groupName') && + Object.keys(data).includes('groupStatus') + ) { + data.data = { + data: data.data, + groupName: data.groupName, + groupStatus: data.groupStatus, + }; + } + return data || []; + }, + function(error) { + return Promise.reject(error); + } +); +export { useHistory, useLocation, useRequest, useRedux }; diff --git a/web/src/index.tsx b/web/src/index.tsx new file mode 100644 index 00000000000..416097fc737 --- /dev/null +++ b/web/src/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from '@/router'; +import * as serviceWorker from './serviceWorker'; + +ReactDOM.render(, document.getElementById('root')); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.unregister(); diff --git a/web/src/pages/Broker/commonModal.tsx b/web/src/pages/Broker/commonModal.tsx new file mode 100644 index 00000000000..8dc8654b3bc --- /dev/null +++ b/web/src/pages/Broker/commonModal.tsx @@ -0,0 +1,274 @@ +import { boolean2Chinese } from '@/utils'; +import Table from '@/components/Tablex'; +import { Col, Form, Input, Row } from 'antd'; +import Modal, { OKProps } from '@/components/Modalx'; +import React from 'react'; +import Query from '@/pages/Broker/query'; +import { FormProps } from 'antd/lib/form'; + +export const OPTIONS = [ + { + value: 'online', + name: '上线', + }, + { + value: 'offline', + name: '下线', + }, + { + value: 'reload', + name: '重载', + }, + { + value: 'delete', + name: '删除', + }, +]; +export const OPTIONS_VALUES = OPTIONS.map(t => t.value); + +// interface +export declare type BrokerData = any[]; +export interface BrokerResultData { + acceptPublish: string; + acceptSubscribe: string; + brokerId: number; + brokerIp: string; + brokerPort: number; + brokerTLSPort: number; + brokerVersion: string; + enableTLS: boolean; + isAutoForbidden: boolean; + isBrokerOnline: string; + isConfChanged: string; + isConfLoaded: string; + isRepAbnormal: boolean; + manageStatus: string; + runStatus: string; + subStatus: string; + [key: string]: any; +} +export interface BrokerModalProps { + type: string; + title: string; + updateFunction: (draft: any) => any; + params?: any; +} +// exports broker modal +// render funcs +const renderBrokerOptions = (modalParams: any, dataSource: any[]) => { + const columns = [ + { + title: 'Broker', + render: (t: string, r: BrokerResultData) => { + return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`; + }, + }, + { + title: 'BrokerIP', + dataIndex: 'brokerIp', + }, + { + title: '管理状态', + dataIndex: 'manageStatus', + }, + { + title: '运行状态', + dataIndex: 'runStatus', + }, + { + title: '运行子状态', + dataIndex: 'subStatus', + }, + { + title: '可发布', + render: (t: string) => boolean2Chinese(t), + }, + { + title: '可订阅', + render: (t: string) => boolean2Chinese(t), + }, + ]; + return
; +}; +const renderNewBroker = (form: any) => { + const brokerFormArr = [ + { + name: 'brokerId', + defaultValue: '0', + }, + { + name: 'numPartitions', + defaultValue: '3', + }, + { + name: 'brokerIp', + defaultValue: '', + }, + { + name: 'brokerPort', + defaultValue: '8123', + }, + { + name: 'deleteWhen', + defaultValue: '0 0 6,18 * * ?', + }, + { + name: 'deletePolicy', + defaultValue: 'delete,168h', + }, + { + name: 'unflushThreshold', + defaultValue: '1000', + }, + { + name: 'unflushInterval', + defaultValue: '10000', + }, + { + name: 'acceptPublish', + defaultValue: 'true', + }, + { + name: 'acceptSubscribe', + defaultValue: 'true', + }, + ]; + + return ( + + + {brokerFormArr.map((t, index) => ( + + + + + + ))} + + + ); +}; +const renderEditBroker = (modalParams: any, form: FormProps['form']) => { + const { params: p } = modalParams; + const pickArr = [ + 'numPartitions', + 'unflushThreshold', + 'unflushInterval', + 'deleteWhen', + 'deletePolicy', + 'acceptPublish', + 'acceptSubscribe', + ]; + const brokerFormArr: Array<{ + name: string; + defaultValue: string; + }> = []; + pickArr.forEach(t => { + brokerFormArr.push({ + name: t, + defaultValue: p[t], + }); + }); + + return ( + + + {brokerFormArr.map((t, index) => ( + + + + + + ))} + + + ); +}; +const renderBrokerStateChange = (modalParams: any) => { + const { params } = modalParams; + + return ( +
+ 请确认{params.option} ID:{' '} + {params.id} 的 Broker? +
+ ); +}; +export const onOpenModal = (p: BrokerModalProps) => { + const { type, title, updateFunction, params } = p; + if (typeof params === 'function') { + p.params = params(); + } + updateFunction((m: any) => { + m.type = type; + m.params = params; + Object.assign(m, { + params, + visible: type, + title, + onOk: (p: OKProps) => { + updateFunction((m: any) => { + if (type === 'newBroker' || type === 'editBroker') { + p.params = f && f.getFieldsValue(); + } + m.okParams = p; + m.isOk = Date.now(); + }); + }, + onCancel: () => { + updateFunction((m: any) => { + m.visible = false; + m.isOk = null; + }); + }, + }); + }); +}; + +interface ComProps { + modalParams: any; + data: any[]; +} +let f: FormProps['form']; +const Comp = (props: ComProps) => { + const { modalParams, data } = props; + const [form] = Form.useForm(); + f = form; + + return ( + +
+ {modalParams.type && + OPTIONS_VALUES.includes(modalParams.type) && + renderBrokerOptions( + modalParams, + data.filter((t: BrokerResultData) => + modalParams.params.includes(t.brokerId) + ) + )} + {modalParams.type === 'newBroker' && renderNewBroker(form)} + {modalParams.type === 'editBroker' && + renderEditBroker(modalParams, form)} + {modalParams.type === 'brokerStateChange' && + renderBrokerStateChange(modalParams)} +
+ +
+ ); +}; + +export default Comp; diff --git a/web/src/pages/Broker/detail.tsx b/web/src/pages/Broker/detail.tsx new file mode 100644 index 00000000000..28a7d06ee09 --- /dev/null +++ b/web/src/pages/Broker/detail.tsx @@ -0,0 +1,378 @@ +import React, { ReactNode, useContext, useState } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import TitleWrap from '@/components/TitleWrap'; +import { Form, Button, Spin, Col, Row, Switch, Tabs } from 'antd'; +import { useImmer } from 'use-immer'; +import './index.less'; +import { useRequest } from '@/hooks'; +import { useParams } from 'react-router-dom'; +import { boolean2Chinese, transParamsWithConstantsMap } from '@/utils'; +import { BROKER_INFO_ZH_MAP } from '@/constants/broker'; +import tableFilterHelper from '@/components/Tablex/tableFilterHelper'; +import CommonModal, { OPTIONS, onOpenModal, BrokerData } from './commonModal'; + +declare type BrokerQueryData = { + withDetail: boolean; + brokerId: string; +}; + +declare type TopicQueryData = { + withTopic: boolean; + brokerId: string; +}; + +const { TabPane } = Tabs; + +const Detail: React.FC = () => { + const { id } = useParams(); + const { breadMap } = useContext(GlobalContext); + const [form] = Form.useForm(); + const [modalParams, updateModelParams] = useImmer({}); + const [acceptPublish, setAcceptPublish] = useState(false); + const [acceptSubscribe, setAcceptSubscribe] = useState(false); + const [filterData, updateFilterData] = useImmer({}); + const queryBrokerConf = useRequest( + ( + data: BrokerQueryData = { + withDetail: true, + brokerId: id, + } + ) => ({ + url: '/api/op_query/admin_query_broker_run_status', + data: { + ...data, + }, + }), + { + onSuccess: data => { + setAcceptPublish(data[0]['acceptPublish'] === 'true'); + setAcceptSubscribe(data[0]['acceptSubscribe'] === 'true'); + }, + } + ); + const queryTopicInfo = useRequest( + ( + data: TopicQueryData = { + withTopic: true, + brokerId: id, + } + ) => ({ + url: '/api/op_query/admin_query_broker_configure', + data: { + ...data, + }, + }) + ); + + // render + const renderConf = () => { + const columns = [ + { + title: '类别', + dataIndex: `type`, + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'), + dataIndex: 'acceptPublish', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'acceptSubscribe' + ), + dataIndex: 'acceptSubscribe', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'unflushThreshold' + ), + dataIndex: 'unflushThreshold', + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'unflushInterval' + ), + dataIndex: 'unflushInterval', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deleteWhen'), + dataIndex: 'deleteWhen', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deletePolicy'), + dataIndex: 'deletePolicy', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'numPartitions'), + dataIndex: 'numPartitions', + }, + { + title: '操作', + render: (t: string, r: BrokerData) => { + return onEditConf(r)}>编辑; + }, + }, + ]; + const { data } = queryBrokerConf; + if (!data || !data[0]) return null; + const { BrokerSyncStatusInfo } = data[0]; + const dataSource = []; + dataSource.push({ + type: '缺省配置', + ...BrokerSyncStatusInfo.curBrokerDefaultConfInfo, + }); + dataSource.push({ + type: '最近上报', + ...BrokerSyncStatusInfo.reportedBrokerDefaultConfInfo, + }); + dataSource.push({ + type: '最近下发', + ...BrokerSyncStatusInfo.lastPushBrokerDefaultConfInfo, + }); + + return
; + }; + const renderTopics = (type: string): ReactNode => { + const columns = [ + { + title: 'topicName', + dataIndex: `topicName`, + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'numPartitions'), + dataIndex: 'numPartitions', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'), + dataIndex: 'acceptPublish', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'acceptSubscribe' + ), + dataIndex: 'acceptSubscribe', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'unflushThreshold' + ), + dataIndex: 'unflushThreshold', + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'unflushInterval' + ), + dataIndex: 'unflushInterval', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deleteWhen'), + dataIndex: 'deleteWhen', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deletePolicy'), + dataIndex: 'deletePolicy', + }, + ]; + const { data } = queryBrokerConf; + if (!data || !data[0]) return null; + const { BrokerSyncStatusInfo } = data[0]; + let dataSource: any[] = []; + if (type === 'cur') { + dataSource = BrokerSyncStatusInfo.curBrokerTopicSetConfInfo; + } else if (type === 'lastPush') { + dataSource = BrokerSyncStatusInfo.lastPushBrokerTopicSetConfInfo; + } else if (type === 'lastReported') { + dataSource = BrokerSyncStatusInfo.reportedBrokerTopicSetConfInfo; + } + + return ( +
type + r.topicName} + dataSourceX={filterData.list} + searchPlaceholder="请输入TopicName搜索" + searchStyle={{ + position: 'absolute', + top: '-55px', + right: '10px', + zIndex: 1, + }} + filterFnX={value => + tableFilterHelper({ + key: value, + srcArray: dataSource, + targetArray: filterData.list, + updateFunction: res => + updateFilterData(filterData => { + filterData.list = res; + }), + filterList: ['topicName'], + }) + } + /> + ); + }; + + // event + // acceptPublish && acceptSubscribe event + const onSwitchChange = (e: boolean, type: string) => { + let option = ''; + if (type === 'acceptPublish') { + option = e ? '发布' : '禁止可发布'; + } else if (type === 'acceptSubscribe') { + option = e ? '订阅' : '禁止可订阅'; + } + + onOpenModal({ + type: 'brokerStateChange', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + option, + id: queryBrokerConf.data[0].brokerId, + callback: () => { + if (type === 'acceptPublish') { + setAcceptPublish(e); + } else if (type === 'acceptSubscribe') { + setAcceptSubscribe(e); + } + }, + }, + }); + }; + + const onOptions = (type: string) => { + onOpenModal({ + type, + title: `确认进行【${OPTIONS.find(t => t.value === type)?.name}】操作?`, + updateFunction: updateModelParams, + params: [queryBrokerConf.data[0].brokerId], + }); + }; + + // new broker + const onEditConf = (r: BrokerData) => { + onOpenModal({ + type: 'editBroker', + title: '编辑Broker', + updateFunction: updateModelParams, + params: r, + }); + }; + + return ( + + +
+ +
+ onSwitchChange(e, 'acceptPublish')} + /> + onSwitchChange(e, 'acceptSubscribe')} + /> + + + +
+
+ + {queryBrokerConf.data && + Object.keys(queryBrokerConf.data[0]).map( + (t: string, index: number) => { + const label = transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + t + ); + const ignoreList = [ + 'acceptPublish', + 'brokerVersion', + 'acceptSubscribe', + ]; + if ( + queryBrokerConf.data[0][t] instanceof Object || + !label || + ignoreList.includes(t) + ) + return null; + return ( +
+ + {queryBrokerConf.data[0][t] + ''} + + + ); + } + )} + + + + {renderConf()} + + + + {renderTopics('cur')} + + + {renderTopics('lastPush')} + + + {renderTopics('lastReported')} + + + + + + + ); +}; + +export default Detail; diff --git a/web/src/pages/Broker/index.less b/web/src/pages/Broker/index.less new file mode 100644 index 00000000000..c95e98d114e --- /dev/null +++ b/web/src/pages/Broker/index.less @@ -0,0 +1,9 @@ +.broker-detail-options-wrapper { + position: absolute; + top: 15px; + right: 0; + + .mr10 { + margin-right: 10px; + } +} \ No newline at end of file diff --git a/web/src/pages/Broker/index.tsx b/web/src/pages/Broker/index.tsx new file mode 100644 index 00000000000..7cc91dc2b59 --- /dev/null +++ b/web/src/pages/Broker/index.tsx @@ -0,0 +1,280 @@ +import React, { useContext, useState } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import { Form, Select, Button, Spin, Switch, message } from 'antd'; +import { useImmer } from 'use-immer'; +import { useRequest } from '@/hooks'; +import tableFilterHelper from '@/components/Tablex/tableFilterHelper'; +import { boolean2Chinese, transParamsWithConstantsMap } from '@/utils'; +import { BROKER_INFO_ZH_MAP } from '@/constants/broker'; +import './index.less'; +import { Link } from 'react-router-dom'; +import CommonModal, { + OPTIONS, + onOpenModal, + BrokerResultData, + BrokerData, +} from './commonModal'; + +const { Option } = Select; +const Broker: React.FC = () => { + // column config + const columns = [ + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerId'), + dataIndex: 'brokerId', + fixed: 'left', + render: (t: Array) => {t}, + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerIp'), + dataIndex: 'brokerIp', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerPort'), + dataIndex: 'brokerPort', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'manageStatus'), + dataIndex: 'manageStatus', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'runStatus'), + dataIndex: 'runStatus', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'subStatus'), + dataIndex: 'subStatus', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'), + dataIndex: 'acceptPublish', + render: (t: string, r: BrokerResultData) => { + return ( + onSwitchChange(e, r, 'acceptPublish')} + /> + ); + }, + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptSubscribe'), + dataIndex: 'acceptSubscribe', + render: (t: string, r: BrokerResultData) => { + return ( + onSwitchChange(e, r, 'acceptSubscribe')} + /> + ); + }, + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isConfChanged'), + dataIndex: 'isConfChanged', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isConfLoaded'), + dataIndex: 'isConfLoaded', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isBrokerOnline'), + dataIndex: 'isBrokerOnline', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'), + dataIndex: 'isBrokerOnline', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'brokerTLSPort'), + dataIndex: 'brokerTLSPort', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'enableTLS'), + dataIndex: 'enableTLS', + render: (t: boolean) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isRepAbnormal'), + dataIndex: 'isRepAbnormal', + render: (t: boolean) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'isAutoForbidden'), + dataIndex: 'isAutoForbidden', + render: (t: boolean) => boolean2Chinese(t), + }, + { + title: '操作', + dataIndex: 'brokerIp', + fixed: 'right', + width: 180, + render: (t: string, r: any) => { + return ( + + {OPTIONS.map(t => ( + onOptionsChange(t.value, r)}> + {t.name} + + ))} + + ); + }, + }, + ]; + const { breadMap } = useContext(GlobalContext); + const [modalParams, updateModelParams] = useImmer({}); + const [filterData, updateFilterData] = useImmer({}); + const [selectBroker, setSelectBroker] = useState([]); + const [brokerList, updateBrokerList] = useImmer([]); + const [form] = Form.useForm(); + // init query + const { data, loading, run } = useRequest( + (data: BrokerResultData) => ({ + url: '/api/op_query/admin_query_broker_run_status', + data: data, + }), + { + onSuccess: data => { + updateBrokerList(d => { + Object.assign(d, data); + }); + }, + } + ); + + // table event + // acceptSubscribe && acceptPublish options + const onSwitchChange = (e: boolean, r: BrokerResultData, type: string) => { + let option = ''; + if (type === 'acceptPublish') { + option = e ? '发布' : '禁止可发布'; + } else if (type === 'acceptSubscribe') { + option = e ? '订阅' : '禁止可订阅'; + } + + onOpenModal({ + type: 'brokerStateChange', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + option, + id: r.brokerId, + type, + callback: () => { + const index = data.findIndex( + (t: BrokerResultData) => t.brokerId === r.brokerId + ); + updateBrokerList(d => { + d[index][type] = e + ''; + }); + }, + }, + }); + }; + // new broker + const onNewBroker = () => { + onOpenModal({ + type: 'newBroker', + title: '新建Broker', + updateFunction: updateModelParams, + }); + }; + // online, offline, etc. + const onOptionsChange = (type: string, r?: BrokerResultData) => { + if (!r && !selectBroker.length) { + form.resetFields(); + return message.error('批量操作至少选择一列!'); + } + + onOpenModal({ + type, + title: `确认进行【${OPTIONS.find(t => t.value === type)?.name}】操作?`, + updateFunction: updateModelParams, + params: r ? [r.brokerId] : selectBroker, + }); + }; + // table select + const onBrokerTableSelectChange = (p: any[]) => { + setSelectBroker(p); + }; + + return ( + + +
+
+
+ + + + + + + + +
+
+ tableFilterHelper({ + key: value, + srcArray: data, + targetArray: filterData.list, + updateFunction: res => + updateFilterData(filterData => { + filterData.list = res; + }), + filterList: [ + 'brokerId', + 'brokerIp', + 'brokerPort', + 'runStatus', + 'subStatus', + 'manageStatus', + ], + }) + } + /> + + + + ); +}; + +export default Broker; diff --git a/web/src/pages/Broker/query.tsx b/web/src/pages/Broker/query.tsx new file mode 100644 index 00000000000..9e89789862f --- /dev/null +++ b/web/src/pages/Broker/query.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import './index.less'; +import { OKProps } from '@/components/Modalx'; +import { useRequest } from '@/hooks'; +import { useContext, useEffect } from 'react'; +import GlobalContext from '@/context/globalContext'; + +interface ComProps { + fire: string; + params: any; + type: string; +} + +const Comp = (props: ComProps) => { + const { fire } = props; + const { userInfo } = useContext(GlobalContext); + // eslint-disable-next-line + useEffect(() => { + const { params, type } = props; + dispatchAction(type, params); + }, [fire, props]); + + const dispatchAction = (type: string, p: OKProps) => { + if (!fire) return null; + let promise; + switch (type) { + case 'newBroker': + promise = newBroker(p); + break; + case 'editBroker': + promise = editBroker(p); + break; + case 'brokerStateChange': + promise = brokerAcceptPublish(type, p); + break; + case 'online': + case 'offline': + case 'reload': + case 'delete': + promise = brokerOptions(type, p); + break; + } + + promise && + promise.then(t => { + const { callback } = p.params; + if (t.statusCode !== 0 && callback) callback(t); + }); + }; + + const newBrokerQuery = useRequest( + data => ({ url: '/api/op_modify/admin_add_broker_configure', ...data }), + { manual: true } + ); + const newBroker = (p: OKProps) => { + const { params } = p; + return newBrokerQuery.run({ + data: { + ...params, + confModAuthToken: p.psw, + createUser: userInfo.userName, + }, + }); + }; + + const updateBrokerQuery = useRequest( + data => ({ url: '/api/op_modify/admin_update_broker_configure', ...data }), + { manual: true } + ); + const editBroker = (p: OKProps) => { + const { params } = p; + return updateBrokerQuery.run({ + data: { + ...params, + confModAuthToken: p.psw, + createUser: userInfo.userName, + }, + }); + }; + + const brokerOptionsQuery = useRequest( + (url, data) => ({ url, ...data }), + { manual: true } + ); + const brokerOptions = (type: string, p: OKProps) => { + const { params } = p; + return brokerOptionsQuery.run( + `/api/op_modify/admin_${type}_broker_configure`, + { + data: { + brokerId: params ? params?.join(',') : params?.selectBroker.join(','), + confModAuthToken: p.psw, + createUser: userInfo.userName, + }, + } + ); + }; + + const brokerAcceptPublishQuery = useRequest( + (url, data) => ({ url, ...data }), + { manual: true } + ); + const brokerAcceptPublish = (type: string, p: OKProps) => { + const { params } = p; + const data: any = { + brokerId: params.id, + confModAuthToken: p.psw, + createUser: userInfo.userName, + }; + if (params.type === 'acceptPublish') { + data.isAcceptPublish = params.option; + } + if (params.type === 'acceptSubscribe') { + data.isAcceptSubscribe = params.option; + } + + return brokerAcceptPublishQuery.run( + `/api/op_modify/admin_set_broker_read_or_write`, + { + data, + } + ); + }; + + return <>; +}; + +export default Comp; diff --git a/web/src/pages/Cluster/index.less b/web/src/pages/Cluster/index.less new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/src/pages/Cluster/index.tsx b/web/src/pages/Cluster/index.tsx new file mode 100644 index 00000000000..6b82403e41f --- /dev/null +++ b/web/src/pages/Cluster/index.tsx @@ -0,0 +1,143 @@ +import React, { useContext } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import { Spin } from 'antd'; +import './index.less'; +import { useRequest } from '@/hooks'; +import Modal, { OKProps } from '@/components/Modalx'; +import { useImmer } from 'use-immer'; + +interface ClusterResultData { + groupName: string; + groupStatus: string; + hostName: string; + index: number; + port: string; + nodeStatus: string; + length: number; +} + +const queryClusterList = (data: ClusterResultData) => ({ + url: '/api/op_query/admin_query_master_group_info', + data: data, +}); + +const Cluster: React.FC = () => { + const { breadMap } = useContext(GlobalContext); + const [modalParams, updateModelParams] = useImmer({ + title: '请确认操作', + }); + const { data, loading } = useRequest(queryClusterList, { + formatResult: d => { + return { + list: d.data.map((t: any) => ({ + groupName: d.groupName, + groupStatus: d.groupStatus, + hostName: t.hostName, + index: t.index, + port: t.port, + nodeStatus: t.statusInfo.nodeStatus, + length: d.data.length, + })), + }; + }, + }); + const columns = [ + { + title: '集群名', + dataIndex: 'groupName', + render: (t: string, r: ClusterResultData, index: number) => { + return { + children: t, + props: { + rowSpan: index === 0 ? r.length : 0, + }, + }; + }, + }, + { + title: '集群状态', + dataIndex: 'groupStatus', + render: (t: string, r: ClusterResultData, index: number) => { + return { + children: t, + props: { + rowSpan: index === 0 ? r.length : 0, + }, + }; + }, + }, + { + title: '节点名', + render: (t: string, r: ClusterResultData) => { + return `${r.groupName}-${r.hostName}`; + }, + }, + { + title: 'IP地址', + render: (t: string, r: ClusterResultData) => { + return `${r.hostName}-${r.port}`; + }, + }, + { + title: '节点名', + dataIndex: 'nodeStatus', + }, + { + title: '操作', + render: (t: string, r: ClusterResultData, index: number) => { + return { + children: ( + + onSwitchCluster(t, r)}>切换 + + ), + props: { + rowSpan: index === 0 ? r.length : 0, + }, + }; + }, + }, + ]; + + const switchClusterQuery = useRequest( + (data?: ClusterResultData) => ({ + url: '/api/op_modify/admin_transfer_current_master', + data, + }), + { manual: true } + ); + const onSwitchCluster = (t: string, r: ClusterResultData) => { + updateModelParams(d => { + d = Object.assign(d, { + visible: true, + onOk: (p: OKProps) => { + switchClusterQuery.run({ + confModAuthToken: p.psw, + }); + }, + onCancel: () => { + updateModelParams((m: any) => { + m.visible = false; + }); + }, + }); + }); + }; + return ( + + +
+
+ + +
+ 确认切换集群? +
+
+ + ); +}; + +export default Cluster; diff --git a/web/src/pages/Issue/consumeGroupDetail.tsx b/web/src/pages/Issue/consumeGroupDetail.tsx new file mode 100644 index 00000000000..3c5090f98ad --- /dev/null +++ b/web/src/pages/Issue/consumeGroupDetail.tsx @@ -0,0 +1,95 @@ +import React, { useContext } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import tableFilterHelper from '@/components/Tablex/tableFilterHelper'; +import { Spin } from 'antd'; +import { useImmer } from 'use-immer'; +import './index.less'; +import { useRequest } from '@/hooks'; +import { useParams } from 'react-router-dom'; + +declare type ConsumeGroupData = any[]; +interface ConsumeGroupQueryData { + consumeGroup: string; +} + +// column config +const columns = [ + { + title: '消费者ID', + dataIndex: 'consumerId', + }, + { + title: '消费Topic', + dataIndex: 'topicName', + }, + { + title: 'broker地址', + dataIndex: 'brokerAddr', + }, + { + title: '分区ID', + dataIndex: 'partId', + }, +]; + +const queryUser = (data: ConsumeGroupQueryData) => ({ + url: '/api/op_query/admin_query_consume_group_detail', + data: data, +}); + +const ConsumeGroupDetail: React.FC = () => { + const { id } = useParams(); + const { breadMap } = useContext(GlobalContext); + const [filterData, updateFilterData] = useImmer({}); + const { data, loading } = useRequest( + () => + queryUser({ + consumeGroup: id, + }), + { + formatResult: data => { + const d = data[0]; + return { + list: d.parInfo.map((t: any) => ({ + consumerId: d.consumerId, + ...t, + })), + }; + }, + } + ); + + return ( + + +
+ + tableFilterHelper({ + key: value, + srcArray: data?.list, + targetArray: filterData.list, + updateFunction: res => + updateFilterData(filterData => { + filterData.list = res; + }), + filterList: ['brokerAddr', 'partId'], + }) + } + >
+
+
+ ); +}; + +export default ConsumeGroupDetail; diff --git a/web/src/pages/Issue/index.less b/web/src/pages/Issue/index.less new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/src/pages/Issue/index.tsx b/web/src/pages/Issue/index.tsx new file mode 100644 index 00000000000..54f5ad3767c --- /dev/null +++ b/web/src/pages/Issue/index.tsx @@ -0,0 +1,98 @@ +import React, { useContext } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import { Form, Input, Button, Spin } from 'antd'; +import { useImmer } from 'use-immer'; +import './index.less'; +import { useRequest } from '@/hooks'; +import { Link } from 'react-router-dom'; + +declare type IssueData = any[]; +interface IssueQueryData { + topicName?: string; + consumeGroup?: string; +} + +// column config +const columns = [ + { + title: '消费组', + dataIndex: 'consumeGroup', + render: (t: Array) => {t}, + }, + { + title: '消费Topic', + dataIndex: 'topicSet', + render: (t: Array) => { + return t.join(','); + }, + }, + { + title: '消费分区', + dataIndex: 'consumerNum', + }, +]; + +const queryIssueList = (data: IssueQueryData) => ({ + url: '/api/op_query/admin_query_sub_info', + data: data, +}); + +const Issue: React.FC = () => { + const { breadMap } = useContext(GlobalContext); + const [form] = Form.useForm(); + const [formValues, updateFormValues] = useImmer({}); + const { data, loading, run } = useRequest(queryIssueList, {}); + + const onValuesChange = (p: any) => { + updateFormValues(d => { + Object.assign(d, p); + }); + }; + const onSearch = () => { + run(formValues); + }; + + const onReset = () => { + form.resetFields(); + run({}); + }; + + return ( + + +
+
+
+ + + + + + + + + + +
+
+
+
+
+ ); +}; + +export default Issue; diff --git a/web/src/pages/NotFound/index.tsx b/web/src/pages/NotFound/index.tsx new file mode 100644 index 00000000000..8a0e971e23d --- /dev/null +++ b/web/src/pages/NotFound/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const NotFound: React.FC = () =>
404
; + +export default NotFound; diff --git a/web/src/pages/Topic/commonModal.tsx b/web/src/pages/Topic/commonModal.tsx new file mode 100644 index 00000000000..9d7783ce7ab --- /dev/null +++ b/web/src/pages/Topic/commonModal.tsx @@ -0,0 +1,349 @@ +import { boolean2Chinese } from '@/utils'; +import Table from '@/components/Tablex'; +import { Col, Form, Input, message, Row } from 'antd'; +import Modal, { OKProps } from '@/components/Modalx'; +import React from 'react'; +import Query from '@/pages/Topic/query'; +import { FormProps } from 'antd/lib/form'; + +export const OPTIONS = [ + { + value: 'delete', + name: '删除', + }, +]; +export const OPTIONS_VALUES = OPTIONS.map(t => t.value); + +// interface +export declare type TopicData = any[]; +export interface TopicResultData { + topicName: string; + infoCount: string; + totalCfgNumPart: string; + totalRunNumPartCount: string; + isSrvAcceptPublish: string | number; + isSrvAcceptSubscribe: string | number; + enableAuthControl: string | number; + [key: string]: any; +} +export interface TopicModalProps { + type: string; + title?: string; + updateFunction: (draft: any) => any; + params?: any; +} +interface ComProps { + modalParams: any; + data: any[]; +} +// exports broker modal +// render funcs +const renderTopicOptions = (modalParams: any, dataSource: any[]) => { + const columns = [ + { + title: 'Topic', + render: (t: string, r: TopicResultData) => { + return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`; + }, + }, + { + title: 'TopicIP', + dataIndex: 'brokerIp', + }, + { + title: '管理状态', + dataIndex: 'manageStatus', + }, + { + title: '运行状态', + dataIndex: 'runStatus', + }, + { + title: '运行子状态', + dataIndex: 'subStatus', + }, + { + title: '可发布', + render: (t: string) => boolean2Chinese(t), + }, + { + title: '可订阅', + render: (t: string) => boolean2Chinese(t), + }, + ]; + return ; +}; +const renderNewTopic = (form: any) => { + const brokerFormArr = [ + { + name: 'topicName', + defaultValue: '', + }, + { + name: 'numPartitions', + defaultValue: '3', + }, + { + name: 'deleteWhen', + defaultValue: '0 0 6,18 * * ?', + }, + { + name: 'deletePolicy', + defaultValue: 'delete,168h', + }, + { + name: 'unflushThreshold', + defaultValue: '1000', + }, + { + name: 'unflushInterval', + defaultValue: '10000', + }, + { + name: 'acceptPublish', + defaultValue: 'true', + }, + { + name: 'acceptSubscribe', + defaultValue: 'true', + }, + ]; + + return ( + + + {brokerFormArr.map((t, index) => ( + + + + + + ))} + + + ); +}; +const renderChooseBroker = (modalParams: any) => { + const { params } = modalParams; + const columns = [ + { + title: 'Broker', + render: (t: string, r: TopicResultData) => { + return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`; + }, + }, + { + title: '实例数', + dataIndex: ['runInfo', 'numTopicStores'], + }, + { + title: '当前运行状态', + dataIndex: ['runInfo', 'brokerManageStatus'], + }, + { + title: '可发布', + dataIndex: ['runInfo', 'acceptPublish'], + render: (t: string) => boolean2Chinese(t), + }, + { + title: '可订阅', + dataIndex: ['runInfo', 'acceptSubscribe'], + render: (t: string) => boolean2Chinese(t), + }, + ]; + const onChangeSelect = (p: any) => { + selectBroker = p; + }; + return ( +
+ ); +}; +const renderEditTopic = (modalParams: any, form: FormProps['form']) => { + const { params: p } = modalParams; + const pickArr = [ + 'topicName', + 'numPartitions', + 'unflushThreshold', + 'unflushInterval', + 'deleteWhen', + 'deletePolicy', + 'acceptPublish', + 'acceptSubscribe', + ]; + const brokerFormArr: Array<{ + name: string; + defaultValue: string; + }> = []; + pickArr.forEach(t => { + brokerFormArr.push({ + name: t, + defaultValue: p[t], + }); + }); + + return ( + + + {brokerFormArr.map((t, index) => ( + + + + + + ))} + + + ); +}; +const renderTopicStateChange = (modalParams: any) => { + const { params } = modalParams; + + return ( +
+ 请确认{params.option} 以下broker列表的 + topic :({params.topicName}) 的 Topic? + {renderChooseBroker(modalParams)} +
+ ); +}; +const renderDeleteTopic = (modalParams: any) => { + const { params } = modalParams; + + return ( +
+ 请确认删除 以下broker列表的 topic : + ({params.topicName}) 吗? + {renderChooseBroker(modalParams)} +
+ ); +}; +const renderDeleteConsumeGroup = (modalParams: any) => { + const { params } = modalParams; + + return ( +
+ 确认删除 以下 : + ({params.groupName}) 吗? +
+ ); +}; +const renderAuthorizeControlChange = (modalParams: any) => { + const { params } = modalParams; + + return ( +
+ 请确认 + + {params.value ? '启动' : '关闭'}topic + ({params.topicName})的消费组授权控制 + + 吗? +
+ ); +}; +export const onOpenModal = (p: TopicModalProps) => { + const { type, title, updateFunction, params } = p; + updateFunction((m: any) => { + m.type = type; + m.params = params; + Object.assign(m, { + params, + visible: type, + title, + onOk: (p: OKProps) => { + updateFunction((m: any) => { + if (type === 'newTopic' || type === 'editTopic') { + p.params = Object.assign(f && f.getFieldsValue(), { + callback: p.params.callback, + }); + } + + if ( + type === 'chooseBroker' || + type === 'topicStateChange' || + type === 'deleteTopic' + ) { + if (!selectBroker.length) { + message.error('至少选择一列!'); + return; + } + + // end + if (type === 'chooseBroker') { + m.query = + p.params.subType === 'edit' + ? 'endEditChooseBroker' + : 'endChooseBroker'; + } + p.params = Object.assign({}, p.params, { + selectBroker, + }); + } + + m.okParams = p; + m.isOk = Date.now(); + }); + }, + onCancel: () => + updateFunction((m: any) => { + m.visible = false; + m.isOk = null; + }), + }); + }); +}; + +let selectBroker: any[] = []; +let f: FormProps['form']; +const Comp = (props: ComProps) => { + const { modalParams, data } = props; + const [form] = Form.useForm(); + f = form; + + return ( + +
+ {modalParams.type && + OPTIONS_VALUES.includes(modalParams.type) && + renderTopicOptions( + modalParams, + data.filter((t: TopicResultData) => + modalParams.params.includes(t.brokerId) + ) + )} + {modalParams.type === 'newTopic' && renderNewTopic(form)} + {modalParams.type === 'chooseBroker' && renderChooseBroker(modalParams)} + {modalParams.type === 'editTopic' && renderEditTopic(modalParams, form)} + {modalParams.type === 'topicStateChange' && + renderTopicStateChange(modalParams)} + {modalParams.type === 'deleteTopic' && renderDeleteTopic(modalParams)} + {modalParams.type === 'deleteConsumeGroup' && + renderDeleteConsumeGroup(modalParams)} + {modalParams.type === 'authorizeControl' && + renderAuthorizeControlChange(modalParams)} +
+ +
+ ); +}; + +export default Comp; diff --git a/web/src/pages/Topic/detail.tsx b/web/src/pages/Topic/detail.tsx new file mode 100644 index 00000000000..0c9965c6d55 --- /dev/null +++ b/web/src/pages/Topic/detail.tsx @@ -0,0 +1,510 @@ +import React, { ReactNode, useContext, useState } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import TitleWrap from '@/components/TitleWrap'; +import { Form, Button, Spin, Col, Row, Switch } from 'antd'; +import { useImmer } from 'use-immer'; +import './index.less'; +import { useRequest } from '@/hooks'; +import { useParams } from 'react-router-dom'; +import { boolean2Chinese, transParamsWithConstantsMap } from '@/utils'; +import tableFilterHelper from '@/components/Tablex/tableFilterHelper'; +import CommonModal, { onOpenModal, TopicResultData } from './commonModal'; +import BrokerModal, { + onOpenModal as onOpenBrokerModal, +} from '@/pages/Broker/commonModal'; +import { BROKER_INFO_ZH_MAP } from '@/constants/broker'; +import { PERSON_INFO_ZH_MAP } from '@/constants/person'; +import { TOPIC_INFO_ZH_MAP } from '@/constants/topic'; + +declare type TopicQueryData = { + topicName: string; +}; + +const Detail: React.FC = () => { + const { name } = useParams(); + const { breadMap } = useContext(GlobalContext); + const [form] = Form.useForm(); + const [modalParams, updateModelParams] = useImmer({}); + const [brokerModalParams, updateBrokerModalParams] = useImmer({}); + const [isSrvAcceptPublish, setIsSrvAcceptPublish] = useState(false); + const [isSrvAcceptSubscribe, setIsSrvAcceptSubscribe] = useState(false); + const [enableAuthControl, setEnableAuthControl] = useState(false); + const [filterData, updateFilterData] = useImmer({}); + const queryTopicInfo = useRequest( + ( + data: TopicQueryData = { + topicName: name, + } + ) => ({ + url: '/api/op_query/admin_query_topic_authorize_control', + data: { + ...data, + }, + }) + ); + const queryTopicConf = useRequest( + ( + data: TopicQueryData = { + topicName: name, + } + ) => ({ + url: '/api/op_query/admin_query_topic_info', + data: { + ...data, + }, + }), + { + onSuccess: data => { + setIsSrvAcceptPublish(data[0]['isSrvAcceptPublish']); + setIsSrvAcceptSubscribe(data[0]['isSrvAcceptSubscribe']); + setEnableAuthControl(data[0]['authData']['enableAuthControl']); + }, + } + ); + + // render + const searchStyle = { + position: 'absolute', + top: '-40px', + right: '10px', + zIndex: 1, + width: '300px', + }; + const renderBrokerList = (): ReactNode => { + const columns = [ + { + title: 'Broker', + render: (t: string, r: TopicResultData) => { + return `${r.brokerId}#${r.brokerIp}:${r.brokerPort}`; + }, + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'runInfo.acceptPublish' + ), + dataIndex: ['runInfo', 'acceptPublish'], + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'runInfo.acceptSubscribe' + ), + dataIndex: ['runInfo', 'acceptSubscribe'], + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'runInfo.numPartitions' + ), + dataIndex: ['runInfo', 'numPartitions'], + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'runInfo.brokerManageStatus' + ), + dataIndex: ['runInfo', 'brokerManageStatus'], + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'acceptPublish'), + dataIndex: 'acceptPublish', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'acceptSubscribe' + ), + dataIndex: 'acceptSubscribe', + render: (t: string) => boolean2Chinese(t), + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'numPartitions'), + dataIndex: 'numPartitions', + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'unflushThreshold' + ), + dataIndex: 'unflushThreshold', + }, + { + title: transParamsWithConstantsMap( + BROKER_INFO_ZH_MAP, + 'unflushInterval' + ), + dataIndex: 'unflushInterval', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deleteWhen'), + dataIndex: 'deleteWhen', + }, + { + title: transParamsWithConstantsMap(BROKER_INFO_ZH_MAP, 'deletePolicy'), + dataIndex: 'deletePolicy', + }, + { + title: '操作', + render: (t: string, r: TopicResultData) => { + return ( + + onEdit(r)}>编辑 + onReload(r)}>重载 + onDeleteBroker(r)}>删除 + + ); + }, + }, + ]; + const { data } = queryTopicConf; + if (!data || !data[0]) return null; + const { topicInfo } = data[0]; + + return ( +
`${r.brokerId}#${r.brokerIp}:${r.brokerPort}`} + dataSourceX={filterData.topicInfoList} + searchPlaceholder="请输入brokerId,Ip,Port搜索" + searchStyle={searchStyle} + filterFnX={value => + tableFilterHelper({ + key: value, + srcArray: topicInfo, + targetArray: filterData.topicInfoList, + updateFunction: res => + updateFilterData(filterData => { + filterData.topicInfoList = res; + }), + filterList: ['brokerId', 'brokerIp', 'brokerPort'], + }) + } + /> + ); + }; + const renderConsumeGroupList = (): ReactNode => { + const columns = [ + { + title: '消费组', + dataIndex: 'groupName', + }, + { + title: transParamsWithConstantsMap(PERSON_INFO_ZH_MAP, 'createUser'), + dataIndex: 'createUser', + }, + { + title: transParamsWithConstantsMap(PERSON_INFO_ZH_MAP, 'createDate'), + dataIndex: 'createDate', + }, + { + title: '操作', + render: (t: string, r: TopicResultData) => { + return ( + + onDeleteConsumeGroup(r)}>删除 + + ); + }, + }, + ]; + const { data } = queryTopicInfo; + if (!data || !data[0]) return null; + const { authConsumeGroup } = data[0]; + + return ( +
`${r.brokerId}#${r.brokerIp}:${r.brokerPort}`} + dataSourceX={filterData.list} + searchPlaceholder="请输入消费组名称搜索" + searchStyle={searchStyle} + filterFnX={value => + tableFilterHelper({ + key: value, + srcArray: authConsumeGroup, + targetArray: filterData.list, + updateFunction: res => + updateFilterData(filterData => { + filterData.list = res; + }), + filterList: ['groupName'], + }) + } + /> + ); + }; + + // event + // isSrvAcceptPublish && isSrvAcceptSubscribe event + const queryBrokerListByTopicNameQuery = useRequest( + data => ({ url: '/api/op_query/admin_query_topic_info', ...data }), + { manual: true } + ); + const onSwitchChange = (e: boolean, type: string) => { + let option = ''; + const topicName = queryTopicConf.data[0].topicInfo[0].topicName; + if (type === 'isSrvAcceptPublish') { + option = e ? '发布' : '禁止可发布'; + } else if (type === 'isSrvAcceptSubscribe') { + option = e ? '订阅' : '禁止可订阅'; + } + + queryBrokerListByTopicNameQuery + .run({ + data: { + topicName, + brokerId: '', + }, + }) + .then((d: TopicResultData) => { + onOpenModal({ + type: 'topicStateChange', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + option, + value: e, + topicName, + data: d[0].topicInfo, + type, + callback: () => { + if (type === 'isSrvAcceptPublish') { + setIsSrvAcceptPublish(e); + } else if (type === 'isSrvAcceptSubscribe') { + setIsSrvAcceptSubscribe(e); + } + }, + }, + }); + }); + }; + // author + const onAuthorizeControl = (e: boolean) => { + const option = e ? '发布' : '禁止可发布'; + const topicName = queryTopicConf.data[0].topicInfo[0].topicName; + onOpenModal({ + type: 'authorizeControl', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + option, + value: e, + topicName, + callback: () => { + setEnableAuthControl(e); + }, + }, + }); + }; + // edit topic + const onEdit = (r?: TopicResultData) => { + const p = r || queryTopicConf.data[0].topicInfo[0]; + onOpenModal({ + type: 'editTopic', + title: '编辑Topic', + updateFunction: updateModelParams, + params: { + ...p, + callback: (d: any) => { + onOpenModal({ + type: 'chooseBroker', + title: '选择【修改】broker列表', + updateFunction: updateModelParams, + params: { + data: d, + subType: 'edit', + callback: () => { + onOpenModal({ + type: 'close', + updateFunction: updateModelParams, + }); + }, + }, + }); + }, + }, + }); + }; + // reload topic + const queryBrokerInfo = useRequest( + data => ({ url: '/api/op_query/admin_query_broker_run_status', ...data }), + { manual: true } + ); + const onReload = (r: TopicResultData) => { + queryBrokerInfo + .run({ + data: { + brokerId: r.brokerId, + }, + }) + .then(data => { + onOpenBrokerModal({ + type: 'reload', + title: `确认进行【重载】操作?`, + updateFunction: updateBrokerModalParams, + params: [data[0].brokerId], + }); + }); + }; + // on delete broker + const onDeleteBroker = (r: TopicResultData) => { + queryBrokerListByTopicNameQuery + .run({ + data: { + topicName: r.topicName, + brokerId: r.brokerId, + }, + }) + .then((d: TopicResultData) => { + onOpenModal({ + type: 'deleteTopic', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + topicName: r.topicName, + data: d[0].topicInfo, + }, + }); + }); + }; + const onDeleteConsumeGroup = (r: TopicResultData) => { + onOpenModal({ + type: 'deleteConsumeGroup', + title: `请确认消费组`, + updateFunction: updateModelParams, + params: { + topicName: r.topicName, + groupName: r.groupName, + }, + }); + }; + + return ( + + +
+ +
+ onSwitchChange(e, 'isSrvAcceptPublish')} + /> + onSwitchChange(e, 'isSrvAcceptSubscribe')} + /> + onAuthorizeControl(e)} + /> +
+
+ + {queryTopicConf.data && + Object.keys(queryTopicConf.data[0]).map( + (t: string, index: number) => { + const label = transParamsWithConstantsMap( + TOPIC_INFO_ZH_MAP, + t + ); + const ignoreList = [ + 'isSrvAcceptPublish', + 'isSrvAcceptSubscribe', + ]; + if ( + queryTopicConf.data[0][t] instanceof Object || + !label || + ignoreList.includes(t) + ) + return null; + return ( +
+ + {queryTopicConf.data[0][t] + ''} + + + ); + } + )} + + + + +
+ +
+
+ + {[ + 'acceptPublish', + 'acceptSubscribe', + 'unflushThreshold', + 'unflushInterval', + 'deleteWhen', + 'deletePolicy', + 'numPartitions', + ].map((t: string, index: number) => { + if ( + !queryTopicConf.data || + !queryTopicConf.data[0].topicInfo[0] + ) + return null; + const value = queryTopicConf.data[0].topicInfo[0][t]; + return ( +
+ + {value + ''} + + + ); + })} + + + + {renderBrokerList()} + {renderConsumeGroupList()} + + + + + ); +}; + +export default Detail; diff --git a/web/src/pages/Topic/index.less b/web/src/pages/Topic/index.less new file mode 100644 index 00000000000..5efa767f1cb --- /dev/null +++ b/web/src/pages/Topic/index.less @@ -0,0 +1,9 @@ +.topic-detail-options-wrapper { + position: absolute; + top: 15px; + right: 0; + + .mr10 { + margin-right: 10px; + } +} \ No newline at end of file diff --git a/web/src/pages/Topic/index.tsx b/web/src/pages/Topic/index.tsx new file mode 100644 index 00000000000..cd112f90f17 --- /dev/null +++ b/web/src/pages/Topic/index.tsx @@ -0,0 +1,279 @@ +import React, { useContext } from 'react'; +import GlobalContext from '@/context/globalContext'; +import Breadcrumb from '@/components/Breadcrumb'; +import Table from '@/components/Tablex'; +import { Form, Button, Spin, Switch } from 'antd'; +import { useImmer } from 'use-immer'; +import { useRequest } from '@/hooks'; +import tableFilterHelper from '@/components/Tablex/tableFilterHelper'; +import { transParamsWithConstantsMap } from '@/utils'; +import { TOPIC_INFO_ZH_MAP } from '@/constants/topic'; +import './index.less'; +import { Link } from 'react-router-dom'; +import CommonModal, { + onOpenModal, + TopicResultData, + TopicData, +} from './commonModal'; + +const Topic: React.FC = () => { + // column config + const columns = [ + { + title: transParamsWithConstantsMap(TOPIC_INFO_ZH_MAP, 'topicName'), + dataIndex: 'topicName', + render: (t: Array) => {t}, + }, + { + title: transParamsWithConstantsMap(TOPIC_INFO_ZH_MAP, 'infoCount'), + dataIndex: 'infoCount', + }, + { + title: transParamsWithConstantsMap(TOPIC_INFO_ZH_MAP, 'totalCfgNumPart'), + dataIndex: 'totalCfgNumPart', + }, + { + title: transParamsWithConstantsMap( + TOPIC_INFO_ZH_MAP, + 'totalRunNumPartCount' + ), + dataIndex: 'totalRunNumPartCount', + }, + { + title: transParamsWithConstantsMap( + TOPIC_INFO_ZH_MAP, + 'isSrvAcceptPublish' + ), + dataIndex: 'isSrvAcceptPublish', + render: (t: boolean, r: TopicResultData) => { + return ( + onSwitchChange(e, r, 'isSrvAcceptPublish')} + /> + ); + }, + }, + { + title: transParamsWithConstantsMap( + TOPIC_INFO_ZH_MAP, + 'isSrvAcceptSubscribe' + ), + dataIndex: 'isSrvAcceptSubscribe', + render: (t: boolean, r: TopicResultData) => { + return ( + onSwitchChange(e, r, 'isSrvAcceptSubscribe')} + /> + ); + }, + }, + { + title: transParamsWithConstantsMap( + TOPIC_INFO_ZH_MAP, + 'enableAuthControl' + ), + dataIndex: 'authData.enableAuthControl', + render: (t: boolean, r: TopicResultData) => { + return ( + onAuthorizeControl(e, r)} + /> + ); + }, + }, + { + title: '操作', + dataIndex: 'topicIp', + render: (t: string, r: any) => { + return onDelete(r)}>删除; + }, + }, + ]; + const { breadMap } = useContext(GlobalContext); + const [modalParams, updateModelParams] = useImmer({}); + const [filterData, updateFilterData] = useImmer({}); + const [topicList, updateTopicList] = useImmer([]); + const [form] = Form.useForm(); + // init query + const { data, loading, run } = useRequest( + (data: TopicResultData) => ({ + url: '/api/op_query/admin_query_topic_info', + data: data, + }), + { + cacheKey: 'topicList', + onSuccess: data => { + updateTopicList(d => { + Object.assign(d, data); + }); + }, + } + ); + + // table event + // acceptSubscribe && acceptPublish options + const queryBrokerListByTopicNameQuery = useRequest( + data => ({ url: '/api/op_query/admin_query_topic_info', ...data }), + { manual: true } + ); + const onSwitchChange = (e: boolean, r: TopicResultData, type: string) => { + let option = ''; + if (type === 'isSrvAcceptPublish') { + option = e ? '发布' : '禁止可发布'; + } else if (type === 'isSrvAcceptSubscribe') { + option = e ? '订阅' : '禁止可订阅'; + } + + queryBrokerListByTopicNameQuery + .run({ + data: { + topicName: r.topicName, + brokerId: '', + }, + }) + .then((d: TopicResultData) => { + onOpenModal({ + type: 'topicStateChange', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + option, + value: e, + topicName: r.topicName, + data: d[0].topicInfo, + type, + callback: () => { + const index = data.findIndex( + (t: TopicResultData) => t.topicName === r.topicName + ); + updateTopicList(d => { + d[index][type] = e + ''; + }); + }, + }, + }); + }); + }; + // author + const onAuthorizeControl = (e: boolean, r: TopicResultData) => { + const option = e ? '发布' : '禁止可发布'; + onOpenModal({ + type: 'authorizeControl', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + option, + value: e, + topicName: r.topicName, + callback: () => { + const index = data.findIndex( + (t: TopicResultData) => t.topicName === r.topicName + ); + updateTopicList(d => { + d[index]['authData']['enableAuthControl'] = e + ''; + }); + }, + }, + }); + }; + // new topic + const onNewTopic = () => { + onOpenModal({ + type: 'newTopic', + title: '新建Topic', + updateFunction: updateModelParams, + params: { + callback: (d: any) => { + onOpenModal({ + type: 'chooseBroker', + title: '选择【新增】broker列表', + updateFunction: updateModelParams, + params: { + data: d, + callback: () => { + onOpenModal({ + type: 'close', + updateFunction: updateModelParams, + }); + }, + }, + }); + }, + }, + }); + }; + // delete + const onDelete = (r: TopicResultData) => { + queryBrokerListByTopicNameQuery + .run({ + data: { + topicName: r.topicName, + brokerId: '', + }, + }) + .then((d: TopicResultData) => { + onOpenModal({ + type: 'deleteTopic', + title: `请确认操作`, + updateFunction: updateModelParams, + params: { + topicName: r.topicName, + data: d[0].topicInfo, + }, + }); + }); + }; + + return ( + + +
+
+
+ + + + + +
+
+ tableFilterHelper({ + key: value, + srcArray: data, + targetArray: filterData.list, + updateFunction: res => + updateFilterData(filterData => { + filterData.list = res; + }), + filterList: ['topicName'], + }) + } + /> + + + + ); +}; + +export default Topic; diff --git a/web/src/pages/Topic/query.tsx b/web/src/pages/Topic/query.tsx new file mode 100644 index 00000000000..bf17545f08f --- /dev/null +++ b/web/src/pages/Topic/query.tsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import './index.less'; +import { OKProps } from '@/components/Modalx'; +import { useRequest } from '@/hooks'; +import { useContext, useEffect } from 'react'; +import GlobalContext from '@/context/globalContext'; + +interface ComProps { + fire: string; + params: any; + type: string; +} + +let newObjectTemp = ''; +let editObjectTemp = ''; +const Comp = (props: ComProps) => { + const { fire } = props; + const { userInfo } = useContext(GlobalContext); + // eslint-disable-next-line + useEffect(() => { + const { params, type } = props; + dispatchAction(type, params); + }, [fire]); + + const dispatchAction = (type: string, p: OKProps) => { + if (!fire) return null; + let promise; + switch (type) { + case 'newTopic': + promise = newTopic(p); + break; + case 'endChooseBroker': + promise = endChooseBroker(p); + break; + case 'editTopic': + promise = editTopic(p); + break; + case 'endEditChooseBroker': + promise = endEditChooseBroker(p); + break; + case 'topicStateChange': + promise = topicStateChange(type, p); + break; + case 'authorizeControl': + promise = authorizeControl(type, p); + break; + case 'deleteTopic': + promise = deleteTopic(type, p); + break; + case 'deleteConsumeGroup': + promise = deleteConsumeGroup(type, p); + break; + } + + promise && + promise.then(t => { + const { callback } = p.params; + if (t.statusCode !== 0 && callback) callback(t); + }); + }; + const commonQuery = useRequest((url, data) => ({ url, ...data }), { + manual: true, + }); + const newTopicQuery = useRequest( + data => ({ + url: '/api/op_query/admin_query_broker_topic_config_info', + ...data, + }), + { manual: true } + ); + const newTopic = (p: OKProps) => { + newObjectTemp = JSON.stringify(p.params); + return newTopicQuery.run({ + data: { + topicName: '', + brokerId: '', + }, + }); + }; + + const endChooseBrokerQuery = useRequest( + data => ({ url: '/api/op_modify/admin_add_new_topic_record', ...data }), + { manual: true } + ); + const endChooseBroker = (p: OKProps) => { + const topicParams = JSON.parse(newObjectTemp); + const { params } = p; + return endChooseBrokerQuery.run({ + data: { + borkerId: params.selectBroker.join(','), + confModAuthToken: p.psw, + ...topicParams, + }, + }); + }; + + const editTopic = (p: OKProps) => { + const { params } = p; + editObjectTemp = JSON.stringify(p.params); + return newTopicQuery.run({ + data: { + topicName: params.topicName, + brokerId: '', + }, + }); + }; + const endEditChooseBroker = (p: OKProps) => { + const topicParams = JSON.parse(editObjectTemp); + const { params } = p; + return commonQuery.run(`/api/op_modify/admin_modify_topic_info`, { + data: { + borkerId: params.selectBroker.join(','), + confModAuthToken: p.psw, + ...topicParams, + }, + }); + }; + + const deleteTopic = (type: string, p: OKProps) => { + const { params } = p; + return commonQuery.run(`/api/op_modify/admin_delete_topic_info`, { + data: { + brokerId: params.selectBroker.join(','), + confModAuthToken: p.psw, + modifyUser: userInfo.userName, + topicName: params.topicName, + }, + }); + }; + const deleteConsumeGroup = (type: string, p: OKProps) => { + const { params } = p; + return commonQuery.run( + `/api/op_modify/admin_delete_allowed_consumer_group_info`, + { + data: { + groupName: params.groupName, + confModAuthToken: p.psw, + topicName: params.topicName, + }, + } + ); + }; + const topicStateChange = (type: string, p: OKProps) => { + const { params } = p; + const data: any = { + brokerId: params.selectBroker.join([',']), + confModAuthToken: p.psw, + modifyUser: userInfo.userName, + topicName: params.topicName, + }; + if (params.type === 'isSrvAcceptPublish') { + data.acceptPublish = params.value; + } + if (params.type === 'isSrvAcceptSubscribe') { + data.acceptSubscribe = params.value; + } + + return commonQuery.run(`/api/op_modify/admin_modify_topic_info`, { + data, + }); + }; + + const authorizeControl = (type: string, p: OKProps) => { + const { params } = p; + const data: any = { + confModAuthToken: p.psw, + topicName: params.topicName, + isEnable: params.value, + modifyUser: userInfo.userName, + }; + + return commonQuery.run(`/api/op_modify/admin_set_topic_authorize_control`, { + data, + }); + }; + + return <>; +}; + +export default Comp; diff --git a/web/src/react-app-env.d.ts b/web/src/react-app-env.d.ts new file mode 100644 index 00000000000..6431bc5fc6b --- /dev/null +++ b/web/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/src/router.tsx b/web/src/router.tsx new file mode 100644 index 00000000000..46df97d51a5 --- /dev/null +++ b/web/src/router.tsx @@ -0,0 +1,55 @@ +import React, { Suspense, lazy, useState } from 'react'; +import { + BrowserRouter as Router, + Switch, + Route, + Redirect, +} from 'react-router-dom'; +import { PageLoading } from '@ant-design/pro-layout'; +import { hot } from 'react-hot-loader/root'; +import { Layout } from '@/components'; +import routes from '@/routes'; +import GlobalContext from '@/context/globalContext'; + +const App = () => { + const [cluster, setCluster] = useState(); + const [breadMap, setBreadMap] = useState(); + // eslint-disable-next-line + const [userInfo, setUserInfo] = useState({ + userName: 'webapi', + }); + + return ( + + + + }> + + {routes.map((route, index: number) => ( + { + const LazyComponent = lazy(route.component); + return ; + }} + /> + ))} + + } + /> + + + + + ); +}; + +export default process.env.NODE_ENV === 'development' ? hot(App) : App; diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx new file mode 100644 index 00000000000..ee36fa22bb3 --- /dev/null +++ b/web/src/routes/index.tsx @@ -0,0 +1,37 @@ +import { RouteProps } from '@/typings'; + +const routes: RouteProps[] = [ + { + path: '/issue/:id', + component: () => import('@/pages/Issue/consumeGroupDetail'), + }, + { + path: '/issue', + component: () => import('@/pages/Issue'), + }, + { + path: '/broker/:id', + component: () => import('@/pages/Broker/detail'), + }, + { + path: '/broker', + component: () => import('@/pages/Broker'), + }, + { + path: '/topic/:name', + component: () => import('@/pages/Topic/detail'), + }, + { + path: '/topic', + component: () => import('@/pages/Topic'), + }, + { + path: '/cluster', + component: () => import('@/pages/Cluster'), + }, + { + component: () => import('@/pages/NotFound'), + }, +]; + +export default routes; diff --git a/web/src/serviceWorker.ts b/web/src/serviceWorker.ts new file mode 100644 index 00000000000..109ab0e1de9 --- /dev/null +++ b/web/src/serviceWorker.ts @@ -0,0 +1,146 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +type Config = { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; +}; + +export function register(config?: Config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW(swUrl: string, config?: Config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl: string, config?: Config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.unregister(); + }) + .catch(error => { + console.error(error.message); + }); + } +} diff --git a/web/src/setupProxy.js b/web/src/setupProxy.js new file mode 100644 index 00000000000..1340128e132 --- /dev/null +++ b/web/src/setupProxy.js @@ -0,0 +1,12 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { createProxyMiddleware } = require('http-proxy-middleware'); + +module.exports = function(app) { + app.use( + createProxyMiddleware('/webapi.htm', { + target: 'http://127.0.0.1:8080', // should set api address + changeOrigin: true, + ws: true, + }) + ); +}; diff --git a/web/src/store/global.ts b/web/src/store/global.ts new file mode 100644 index 00000000000..18d86930af6 --- /dev/null +++ b/web/src/store/global.ts @@ -0,0 +1,30 @@ +import { createStore } from '@reactseed/use-redux'; + +export interface TState { + name: string; + age: number; +} + +export interface TMethod { + updateName: (name: string) => void; + becomeOlder: () => void; +} + +const store = createStore(() => ({ + age: 20, + name: 'reactseed', +})); + +const methods = (state: TState): TMethod => { + const { age } = state; + return { + updateName: (name: string) => { + state.name = name; + }, + becomeOlder: () => { + state.age = age + 1; + }, + }; +}; + +export { store, methods }; diff --git a/web/src/typings/index.ts b/web/src/typings/index.ts new file mode 100644 index 00000000000..164ab508b5c --- /dev/null +++ b/web/src/typings/index.ts @@ -0,0 +1 @@ +export * from './router'; diff --git a/web/src/typings/router.ts b/web/src/typings/router.ts new file mode 100644 index 00000000000..38e855dc252 --- /dev/null +++ b/web/src/typings/router.ts @@ -0,0 +1,14 @@ +import { Route as LayoutRoute } from '@ant-design/pro-layout/lib/typings'; +import { RouteProps as ReactRouteProps } from 'react-router-dom'; + +export interface Route extends LayoutRoute { + paths?: string[]; +} + +export type OmitRouteProps = Omit & { + component: () => Promise<{ default: any }>; +}; + +export interface RouteProps extends OmitRouteProps { + component: () => Promise<{ default: any }>; +} diff --git a/web/src/utils/index.ts b/web/src/utils/index.ts new file mode 100644 index 00000000000..e410d917bfa --- /dev/null +++ b/web/src/utils/index.ts @@ -0,0 +1,45 @@ +import { isObject, isEmpty } from 'lodash'; + +export const isDevelopEnv = () => { + return process.env.NODE_ENV === 'development'; +}; + +export const isEmptyParam = (value: any): boolean => { + if (Array.isArray(value)) { + // value为数组 + return !value.length; + } + if (isObject(value)) { + // value为对象 + return isEmpty(value); + } + if (typeof value === 'undefined') { + // value为undefinded + return true; + } + if (Number.isFinite(value)) { + // value为数值 + return false; + } + // value为默认值 + return !value; +}; + +export const boolean2Chinese = (value: boolean | string): string => { + let v: boolean; + if (value === 'false') { + v = false; + } else if (value === 'true') { + v = true; + } else { + v = value as boolean; + } + return !v ? '否' : '是'; +}; + +export const transParamsWithConstantsMap = ( + map: any, + paramsName: string +): string => { + return map[paramsName] || paramsName; +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000000..01240365d36 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "./tsconfig.paths.json", + "compilerOptions": { + "typeRoots": [ + "./typings" + ], + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "exclude": [ + "build", + "node_modules" + ], + "include": [ + "src" + ] +} diff --git a/web/tsconfig.paths.json b/web/tsconfig.paths.json new file mode 100644 index 00000000000..5879100cb59 --- /dev/null +++ b/web/tsconfig.paths.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "paths": { + "@/*": ["*"] + } + } +} From 3ecd108c5d3e218039557d130008e8f267b247d0 Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Wed, 18 Nov 2020 09:46:47 +0800 Subject: [PATCH 7/9] [TUBEMQ-415] exclude apache license for front end code (#318) --- pom.xml | 1 + {web => tubemq-web}/.env | 0 {web => tubemq-web}/.eslintignore | 0 {web => tubemq-web}/.eslintrc | 0 {web => tubemq-web}/.gitignore | 0 {web => tubemq-web}/.prettierrc | 0 {web => tubemq-web}/.stylelintrc | 0 {web => tubemq-web}/README.md | 0 {web => tubemq-web}/config-overrides.js | 0 {web => tubemq-web}/mock/_constant.js | 0 {web => tubemq-web}/mock/app.js | 0 {web => tubemq-web}/package.json | 0 {web => tubemq-web}/public/favicon.ico | Bin {web => tubemq-web}/public/index.html | 0 {web => tubemq-web}/public/logo192.png | Bin {web => tubemq-web}/public/logo512.png | Bin {web => tubemq-web}/public/manifest.json | 0 {web => tubemq-web}/public/robots.txt | 0 .../src/components/Breadcrumb/index.less | 0 .../src/components/Breadcrumb/index.tsx | 0 .../src/components/Layout/index.less | 0 {web => tubemq-web}/src/components/Layout/index.tsx | 0 .../src/components/Modalx/index.less | 0 {web => tubemq-web}/src/components/Modalx/index.tsx | 0 .../src/components/Tablex/index.less | 0 {web => tubemq-web}/src/components/Tablex/index.tsx | 0 .../src/components/Tablex/tableFilterHelper.ts | 0 .../src/components/TitleWrap/index.less | 0 .../src/components/TitleWrap/index.tsx | 0 {web => tubemq-web}/src/components/index.tsx | 0 {web => tubemq-web}/src/configs/index.ts | 0 {web => tubemq-web}/src/configs/menus/index.tsx | 0 {web => tubemq-web}/src/constants/broker.ts | 0 {web => tubemq-web}/src/constants/person.ts | 0 {web => tubemq-web}/src/constants/topic.ts | 0 {web => tubemq-web}/src/context/globalContext.ts | 0 {web => tubemq-web}/src/defaultSettings.js | 0 {web => tubemq-web}/src/hooks/index.ts | 0 {web => tubemq-web}/src/index.tsx | 0 .../src/pages/Broker/commonModal.tsx | 0 {web => tubemq-web}/src/pages/Broker/detail.tsx | 0 {web => tubemq-web}/src/pages/Broker/index.less | 0 {web => tubemq-web}/src/pages/Broker/index.tsx | 0 {web => tubemq-web}/src/pages/Broker/query.tsx | 0 {web => tubemq-web}/src/pages/Cluster/index.less | 0 {web => tubemq-web}/src/pages/Cluster/index.tsx | 0 .../src/pages/Issue/consumeGroupDetail.tsx | 0 {web => tubemq-web}/src/pages/Issue/index.less | 0 {web => tubemq-web}/src/pages/Issue/index.tsx | 0 {web => tubemq-web}/src/pages/NotFound/index.tsx | 0 {web => tubemq-web}/src/pages/Topic/commonModal.tsx | 0 {web => tubemq-web}/src/pages/Topic/detail.tsx | 0 {web => tubemq-web}/src/pages/Topic/index.less | 0 {web => tubemq-web}/src/pages/Topic/index.tsx | 0 {web => tubemq-web}/src/pages/Topic/query.tsx | 0 {web => tubemq-web}/src/react-app-env.d.ts | 0 {web => tubemq-web}/src/router.tsx | 0 {web => tubemq-web}/src/routes/index.tsx | 0 {web => tubemq-web}/src/serviceWorker.ts | 0 {web => tubemq-web}/src/setupProxy.js | 0 {web => tubemq-web}/src/store/global.ts | 0 {web => tubemq-web}/src/typings/index.ts | 0 {web => tubemq-web}/src/typings/router.ts | 0 {web => tubemq-web}/src/utils/index.ts | 0 {web => tubemq-web}/tsconfig.json | 0 {web => tubemq-web}/tsconfig.paths.json | 0 66 files changed, 1 insertion(+) rename {web => tubemq-web}/.env (100%) rename {web => tubemq-web}/.eslintignore (100%) rename {web => tubemq-web}/.eslintrc (100%) rename {web => tubemq-web}/.gitignore (100%) rename {web => tubemq-web}/.prettierrc (100%) rename {web => tubemq-web}/.stylelintrc (100%) rename {web => tubemq-web}/README.md (100%) rename {web => tubemq-web}/config-overrides.js (100%) rename {web => tubemq-web}/mock/_constant.js (100%) rename {web => tubemq-web}/mock/app.js (100%) rename {web => tubemq-web}/package.json (100%) rename {web => tubemq-web}/public/favicon.ico (100%) rename {web => tubemq-web}/public/index.html (100%) rename {web => tubemq-web}/public/logo192.png (100%) rename {web => tubemq-web}/public/logo512.png (100%) rename {web => tubemq-web}/public/manifest.json (100%) rename {web => tubemq-web}/public/robots.txt (100%) rename {web => tubemq-web}/src/components/Breadcrumb/index.less (100%) rename {web => tubemq-web}/src/components/Breadcrumb/index.tsx (100%) rename {web => tubemq-web}/src/components/Layout/index.less (100%) rename {web => tubemq-web}/src/components/Layout/index.tsx (100%) rename {web => tubemq-web}/src/components/Modalx/index.less (100%) rename {web => tubemq-web}/src/components/Modalx/index.tsx (100%) rename {web => tubemq-web}/src/components/Tablex/index.less (100%) rename {web => tubemq-web}/src/components/Tablex/index.tsx (100%) rename {web => tubemq-web}/src/components/Tablex/tableFilterHelper.ts (100%) rename {web => tubemq-web}/src/components/TitleWrap/index.less (100%) rename {web => tubemq-web}/src/components/TitleWrap/index.tsx (100%) rename {web => tubemq-web}/src/components/index.tsx (100%) rename {web => tubemq-web}/src/configs/index.ts (100%) rename {web => tubemq-web}/src/configs/menus/index.tsx (100%) rename {web => tubemq-web}/src/constants/broker.ts (100%) rename {web => tubemq-web}/src/constants/person.ts (100%) rename {web => tubemq-web}/src/constants/topic.ts (100%) rename {web => tubemq-web}/src/context/globalContext.ts (100%) rename {web => tubemq-web}/src/defaultSettings.js (100%) rename {web => tubemq-web}/src/hooks/index.ts (100%) rename {web => tubemq-web}/src/index.tsx (100%) rename {web => tubemq-web}/src/pages/Broker/commonModal.tsx (100%) rename {web => tubemq-web}/src/pages/Broker/detail.tsx (100%) rename {web => tubemq-web}/src/pages/Broker/index.less (100%) rename {web => tubemq-web}/src/pages/Broker/index.tsx (100%) rename {web => tubemq-web}/src/pages/Broker/query.tsx (100%) rename {web => tubemq-web}/src/pages/Cluster/index.less (100%) rename {web => tubemq-web}/src/pages/Cluster/index.tsx (100%) rename {web => tubemq-web}/src/pages/Issue/consumeGroupDetail.tsx (100%) rename {web => tubemq-web}/src/pages/Issue/index.less (100%) rename {web => tubemq-web}/src/pages/Issue/index.tsx (100%) rename {web => tubemq-web}/src/pages/NotFound/index.tsx (100%) rename {web => tubemq-web}/src/pages/Topic/commonModal.tsx (100%) rename {web => tubemq-web}/src/pages/Topic/detail.tsx (100%) rename {web => tubemq-web}/src/pages/Topic/index.less (100%) rename {web => tubemq-web}/src/pages/Topic/index.tsx (100%) rename {web => tubemq-web}/src/pages/Topic/query.tsx (100%) rename {web => tubemq-web}/src/react-app-env.d.ts (100%) rename {web => tubemq-web}/src/router.tsx (100%) rename {web => tubemq-web}/src/routes/index.tsx (100%) rename {web => tubemq-web}/src/serviceWorker.ts (100%) rename {web => tubemq-web}/src/setupProxy.js (100%) rename {web => tubemq-web}/src/store/global.ts (100%) rename {web => tubemq-web}/src/typings/index.ts (100%) rename {web => tubemq-web}/src/typings/router.ts (100%) rename {web => tubemq-web}/src/utils/index.ts (100%) rename {web => tubemq-web}/tsconfig.json (100%) rename {web => tubemq-web}/tsconfig.paths.json (100%) diff --git a/pom.xml b/pom.xml index 296ce8aa4c3..e33e93c3536 100644 --- a/pom.xml +++ b/pom.xml @@ -264,6 +264,7 @@ resources/assets/lib/** resources/assets/public/** DISCLAIMER-WIP + tubemq-web/** **/tubemq-client-twins/tubemq-client-cpp/third_party/** diff --git a/web/.env b/tubemq-web/.env similarity index 100% rename from web/.env rename to tubemq-web/.env diff --git a/web/.eslintignore b/tubemq-web/.eslintignore similarity index 100% rename from web/.eslintignore rename to tubemq-web/.eslintignore diff --git a/web/.eslintrc b/tubemq-web/.eslintrc similarity index 100% rename from web/.eslintrc rename to tubemq-web/.eslintrc diff --git a/web/.gitignore b/tubemq-web/.gitignore similarity index 100% rename from web/.gitignore rename to tubemq-web/.gitignore diff --git a/web/.prettierrc b/tubemq-web/.prettierrc similarity index 100% rename from web/.prettierrc rename to tubemq-web/.prettierrc diff --git a/web/.stylelintrc b/tubemq-web/.stylelintrc similarity index 100% rename from web/.stylelintrc rename to tubemq-web/.stylelintrc diff --git a/web/README.md b/tubemq-web/README.md similarity index 100% rename from web/README.md rename to tubemq-web/README.md diff --git a/web/config-overrides.js b/tubemq-web/config-overrides.js similarity index 100% rename from web/config-overrides.js rename to tubemq-web/config-overrides.js diff --git a/web/mock/_constant.js b/tubemq-web/mock/_constant.js similarity index 100% rename from web/mock/_constant.js rename to tubemq-web/mock/_constant.js diff --git a/web/mock/app.js b/tubemq-web/mock/app.js similarity index 100% rename from web/mock/app.js rename to tubemq-web/mock/app.js diff --git a/web/package.json b/tubemq-web/package.json similarity index 100% rename from web/package.json rename to tubemq-web/package.json diff --git a/web/public/favicon.ico b/tubemq-web/public/favicon.ico similarity index 100% rename from web/public/favicon.ico rename to tubemq-web/public/favicon.ico diff --git a/web/public/index.html b/tubemq-web/public/index.html similarity index 100% rename from web/public/index.html rename to tubemq-web/public/index.html diff --git a/web/public/logo192.png b/tubemq-web/public/logo192.png similarity index 100% rename from web/public/logo192.png rename to tubemq-web/public/logo192.png diff --git a/web/public/logo512.png b/tubemq-web/public/logo512.png similarity index 100% rename from web/public/logo512.png rename to tubemq-web/public/logo512.png diff --git a/web/public/manifest.json b/tubemq-web/public/manifest.json similarity index 100% rename from web/public/manifest.json rename to tubemq-web/public/manifest.json diff --git a/web/public/robots.txt b/tubemq-web/public/robots.txt similarity index 100% rename from web/public/robots.txt rename to tubemq-web/public/robots.txt diff --git a/web/src/components/Breadcrumb/index.less b/tubemq-web/src/components/Breadcrumb/index.less similarity index 100% rename from web/src/components/Breadcrumb/index.less rename to tubemq-web/src/components/Breadcrumb/index.less diff --git a/web/src/components/Breadcrumb/index.tsx b/tubemq-web/src/components/Breadcrumb/index.tsx similarity index 100% rename from web/src/components/Breadcrumb/index.tsx rename to tubemq-web/src/components/Breadcrumb/index.tsx diff --git a/web/src/components/Layout/index.less b/tubemq-web/src/components/Layout/index.less similarity index 100% rename from web/src/components/Layout/index.less rename to tubemq-web/src/components/Layout/index.less diff --git a/web/src/components/Layout/index.tsx b/tubemq-web/src/components/Layout/index.tsx similarity index 100% rename from web/src/components/Layout/index.tsx rename to tubemq-web/src/components/Layout/index.tsx diff --git a/web/src/components/Modalx/index.less b/tubemq-web/src/components/Modalx/index.less similarity index 100% rename from web/src/components/Modalx/index.less rename to tubemq-web/src/components/Modalx/index.less diff --git a/web/src/components/Modalx/index.tsx b/tubemq-web/src/components/Modalx/index.tsx similarity index 100% rename from web/src/components/Modalx/index.tsx rename to tubemq-web/src/components/Modalx/index.tsx diff --git a/web/src/components/Tablex/index.less b/tubemq-web/src/components/Tablex/index.less similarity index 100% rename from web/src/components/Tablex/index.less rename to tubemq-web/src/components/Tablex/index.less diff --git a/web/src/components/Tablex/index.tsx b/tubemq-web/src/components/Tablex/index.tsx similarity index 100% rename from web/src/components/Tablex/index.tsx rename to tubemq-web/src/components/Tablex/index.tsx diff --git a/web/src/components/Tablex/tableFilterHelper.ts b/tubemq-web/src/components/Tablex/tableFilterHelper.ts similarity index 100% rename from web/src/components/Tablex/tableFilterHelper.ts rename to tubemq-web/src/components/Tablex/tableFilterHelper.ts diff --git a/web/src/components/TitleWrap/index.less b/tubemq-web/src/components/TitleWrap/index.less similarity index 100% rename from web/src/components/TitleWrap/index.less rename to tubemq-web/src/components/TitleWrap/index.less diff --git a/web/src/components/TitleWrap/index.tsx b/tubemq-web/src/components/TitleWrap/index.tsx similarity index 100% rename from web/src/components/TitleWrap/index.tsx rename to tubemq-web/src/components/TitleWrap/index.tsx diff --git a/web/src/components/index.tsx b/tubemq-web/src/components/index.tsx similarity index 100% rename from web/src/components/index.tsx rename to tubemq-web/src/components/index.tsx diff --git a/web/src/configs/index.ts b/tubemq-web/src/configs/index.ts similarity index 100% rename from web/src/configs/index.ts rename to tubemq-web/src/configs/index.ts diff --git a/web/src/configs/menus/index.tsx b/tubemq-web/src/configs/menus/index.tsx similarity index 100% rename from web/src/configs/menus/index.tsx rename to tubemq-web/src/configs/menus/index.tsx diff --git a/web/src/constants/broker.ts b/tubemq-web/src/constants/broker.ts similarity index 100% rename from web/src/constants/broker.ts rename to tubemq-web/src/constants/broker.ts diff --git a/web/src/constants/person.ts b/tubemq-web/src/constants/person.ts similarity index 100% rename from web/src/constants/person.ts rename to tubemq-web/src/constants/person.ts diff --git a/web/src/constants/topic.ts b/tubemq-web/src/constants/topic.ts similarity index 100% rename from web/src/constants/topic.ts rename to tubemq-web/src/constants/topic.ts diff --git a/web/src/context/globalContext.ts b/tubemq-web/src/context/globalContext.ts similarity index 100% rename from web/src/context/globalContext.ts rename to tubemq-web/src/context/globalContext.ts diff --git a/web/src/defaultSettings.js b/tubemq-web/src/defaultSettings.js similarity index 100% rename from web/src/defaultSettings.js rename to tubemq-web/src/defaultSettings.js diff --git a/web/src/hooks/index.ts b/tubemq-web/src/hooks/index.ts similarity index 100% rename from web/src/hooks/index.ts rename to tubemq-web/src/hooks/index.ts diff --git a/web/src/index.tsx b/tubemq-web/src/index.tsx similarity index 100% rename from web/src/index.tsx rename to tubemq-web/src/index.tsx diff --git a/web/src/pages/Broker/commonModal.tsx b/tubemq-web/src/pages/Broker/commonModal.tsx similarity index 100% rename from web/src/pages/Broker/commonModal.tsx rename to tubemq-web/src/pages/Broker/commonModal.tsx diff --git a/web/src/pages/Broker/detail.tsx b/tubemq-web/src/pages/Broker/detail.tsx similarity index 100% rename from web/src/pages/Broker/detail.tsx rename to tubemq-web/src/pages/Broker/detail.tsx diff --git a/web/src/pages/Broker/index.less b/tubemq-web/src/pages/Broker/index.less similarity index 100% rename from web/src/pages/Broker/index.less rename to tubemq-web/src/pages/Broker/index.less diff --git a/web/src/pages/Broker/index.tsx b/tubemq-web/src/pages/Broker/index.tsx similarity index 100% rename from web/src/pages/Broker/index.tsx rename to tubemq-web/src/pages/Broker/index.tsx diff --git a/web/src/pages/Broker/query.tsx b/tubemq-web/src/pages/Broker/query.tsx similarity index 100% rename from web/src/pages/Broker/query.tsx rename to tubemq-web/src/pages/Broker/query.tsx diff --git a/web/src/pages/Cluster/index.less b/tubemq-web/src/pages/Cluster/index.less similarity index 100% rename from web/src/pages/Cluster/index.less rename to tubemq-web/src/pages/Cluster/index.less diff --git a/web/src/pages/Cluster/index.tsx b/tubemq-web/src/pages/Cluster/index.tsx similarity index 100% rename from web/src/pages/Cluster/index.tsx rename to tubemq-web/src/pages/Cluster/index.tsx diff --git a/web/src/pages/Issue/consumeGroupDetail.tsx b/tubemq-web/src/pages/Issue/consumeGroupDetail.tsx similarity index 100% rename from web/src/pages/Issue/consumeGroupDetail.tsx rename to tubemq-web/src/pages/Issue/consumeGroupDetail.tsx diff --git a/web/src/pages/Issue/index.less b/tubemq-web/src/pages/Issue/index.less similarity index 100% rename from web/src/pages/Issue/index.less rename to tubemq-web/src/pages/Issue/index.less diff --git a/web/src/pages/Issue/index.tsx b/tubemq-web/src/pages/Issue/index.tsx similarity index 100% rename from web/src/pages/Issue/index.tsx rename to tubemq-web/src/pages/Issue/index.tsx diff --git a/web/src/pages/NotFound/index.tsx b/tubemq-web/src/pages/NotFound/index.tsx similarity index 100% rename from web/src/pages/NotFound/index.tsx rename to tubemq-web/src/pages/NotFound/index.tsx diff --git a/web/src/pages/Topic/commonModal.tsx b/tubemq-web/src/pages/Topic/commonModal.tsx similarity index 100% rename from web/src/pages/Topic/commonModal.tsx rename to tubemq-web/src/pages/Topic/commonModal.tsx diff --git a/web/src/pages/Topic/detail.tsx b/tubemq-web/src/pages/Topic/detail.tsx similarity index 100% rename from web/src/pages/Topic/detail.tsx rename to tubemq-web/src/pages/Topic/detail.tsx diff --git a/web/src/pages/Topic/index.less b/tubemq-web/src/pages/Topic/index.less similarity index 100% rename from web/src/pages/Topic/index.less rename to tubemq-web/src/pages/Topic/index.less diff --git a/web/src/pages/Topic/index.tsx b/tubemq-web/src/pages/Topic/index.tsx similarity index 100% rename from web/src/pages/Topic/index.tsx rename to tubemq-web/src/pages/Topic/index.tsx diff --git a/web/src/pages/Topic/query.tsx b/tubemq-web/src/pages/Topic/query.tsx similarity index 100% rename from web/src/pages/Topic/query.tsx rename to tubemq-web/src/pages/Topic/query.tsx diff --git a/web/src/react-app-env.d.ts b/tubemq-web/src/react-app-env.d.ts similarity index 100% rename from web/src/react-app-env.d.ts rename to tubemq-web/src/react-app-env.d.ts diff --git a/web/src/router.tsx b/tubemq-web/src/router.tsx similarity index 100% rename from web/src/router.tsx rename to tubemq-web/src/router.tsx diff --git a/web/src/routes/index.tsx b/tubemq-web/src/routes/index.tsx similarity index 100% rename from web/src/routes/index.tsx rename to tubemq-web/src/routes/index.tsx diff --git a/web/src/serviceWorker.ts b/tubemq-web/src/serviceWorker.ts similarity index 100% rename from web/src/serviceWorker.ts rename to tubemq-web/src/serviceWorker.ts diff --git a/web/src/setupProxy.js b/tubemq-web/src/setupProxy.js similarity index 100% rename from web/src/setupProxy.js rename to tubemq-web/src/setupProxy.js diff --git a/web/src/store/global.ts b/tubemq-web/src/store/global.ts similarity index 100% rename from web/src/store/global.ts rename to tubemq-web/src/store/global.ts diff --git a/web/src/typings/index.ts b/tubemq-web/src/typings/index.ts similarity index 100% rename from web/src/typings/index.ts rename to tubemq-web/src/typings/index.ts diff --git a/web/src/typings/router.ts b/tubemq-web/src/typings/router.ts similarity index 100% rename from web/src/typings/router.ts rename to tubemq-web/src/typings/router.ts diff --git a/web/src/utils/index.ts b/tubemq-web/src/utils/index.ts similarity index 100% rename from web/src/utils/index.ts rename to tubemq-web/src/utils/index.ts diff --git a/web/tsconfig.json b/tubemq-web/tsconfig.json similarity index 100% rename from web/tsconfig.json rename to tubemq-web/tsconfig.json diff --git a/web/tsconfig.paths.json b/tubemq-web/tsconfig.paths.json similarity index 100% rename from web/tsconfig.paths.json rename to tubemq-web/tsconfig.paths.json From fb84d678f8dbad2951e01b43f0b4262260d2f506 Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Wed, 18 Nov 2020 19:53:42 +0800 Subject: [PATCH 8/9] [TUBEMQ-412] tube manager start stop scrrpts (#317) * [TUBEMQ-412] tube manager start stop scrrpts --- tubemq-manager/bin/start-manager.sh | 60 +++++++++++++++++++++++++++++ tubemq-manager/bin/stop-manager.sh | 31 +++++++++++++++ 2 files changed, 91 insertions(+) create mode 100755 tubemq-manager/bin/start-manager.sh create mode 100755 tubemq-manager/bin/stop-manager.sh diff --git a/tubemq-manager/bin/start-manager.sh b/tubemq-manager/bin/start-manager.sh new file mode 100755 index 00000000000..19acbef8a4c --- /dev/null +++ b/tubemq-manager/bin/start-manager.sh @@ -0,0 +1,60 @@ +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions an +# limitations under the License. + +base_dir=$(dirname $0) + +DAEMON_NAME=${DAEMON_NAME:-"tubemq-manager"} +LOG_DIR=${LOG_DIR:-"$base_dir/../logs"} +CONF_DIR=${CONF_DIR:-"$base_dir/../conf"} +LIB_DIR=${LIB_DIR:-"$base_dir/../lib"} +CONSOLE_OUTPUT_FILE=$LOG_DIR/$DAEMON_NAME.out +MANAGER_HEAP_OPTS="-Xmx16G -Xms16G" +MANAGER_GC_OPTS="-XX:+UseG1GC -verbose:gc -verbose:sizes -Xloggc:${LOG_DIR}/gc.log.`date +%Y-%m-%d-%H-%M-%S` -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution" + +# create logs directory +if [ ! -d "$LOG_DIR" ]; then + mkdir -p "$LOG_DIR" +fi + +# Exclude jars not necessary for running commands. +regex="(-(test|test-sources|src|scaladoc|javadoc)\.jar|jar.asc)$" +should_include_file() { + if [ "$INCLUDE_TEST_JARS" = true ]; then + return 0 + fi + file=$1 + if [ -z "$(echo "$file" | egrep "$regex")" ] ; then + return 0 + else + return 1 + fi +} + +for file in ${LIB_DIR}/*.jar; +do + if should_include_file "$file"; then + CLASSPATH="$CLASSPATH":"$file" + fi +done + +CLASSPATH="${CONF_DIR}":$CLASSPATH +export MANAGER_JVM_OPTS="${MANAGER_HEAP_OPTS} ${MANAGER_GC_OPTS} -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR}" + +# Which java to use +if [ -z "$JAVA_HOME" ]; then + JAVA="java" +else + JAVA="$JAVA_HOME/bin/java" +fi + +nohup "$JAVA" $MANAGER_JVM_OPTS -cp "$CLASSPATH" org.apache.tubemq.manager.TubeMQManager "$@" > "$CONSOLE_OUTPUT_FILE" 2>&1 < /dev/null & diff --git a/tubemq-manager/bin/stop-manager.sh b/tubemq-manager/bin/stop-manager.sh new file mode 100755 index 00000000000..a3b7abdeb14 --- /dev/null +++ b/tubemq-manager/bin/stop-manager.sh @@ -0,0 +1,31 @@ +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions an +# limitations under the License. + +SIGNAL=${SIGNAL:-TERM} + +if [[ $(uname -s) == "OS/390" ]]; then + if [ -z $JOBNAME ]; then + JOBNAME="TubeMQManager" + fi + PIDS=$(ps -A -o pid,jobname,comm | grep -i $JOBNAME | grep java | grep -v grep | awk '{print $1}') +else + PIDS=$(jcmd | grep -i 'TubeMQManager' | awk '{print $1}') +fi + +if [ -z "$PIDS" ]; then + echo "No tubemq manager server to stop" + exit 1 +else + kill -s $SIGNAL $PIDS + echo "stop tubemq manager .... $PIDS" +fi From 343f2cccde0b7cab0c0a537f5efce99e33053110 Mon Sep 17 00:00:00 2001 From: Yuanbo Liu Date: Tue, 24 Nov 2020 15:00:52 +0800 Subject: [PATCH 9/9] [TUBEMQ-425] add readme to setup manager cluster (#324) --- tubemq-manager/READMe.md | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tubemq-manager/READMe.md diff --git a/tubemq-manager/READMe.md b/tubemq-manager/READMe.md new file mode 100644 index 00000000000..29f5fdc7f0e --- /dev/null +++ b/tubemq-manager/READMe.md @@ -0,0 +1,51 @@ +License +======= + +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. + +# introduction +tubemq-manager is used to manage multiple tubemq cluster. It works with tubemq-web project. +tubemq-manager provide restful api and tubemq-web use them to provide front-end web pages. +This page is going to introduce how to set up tubemq-manager environment. + + +# build +```shell script +mvn clean package +``` + + +# distribution +env requirements: + 1. mysql + 2. java(1.8+) + +In the dist directory, you can find a installable file called `tubemq-manager-bin.zip`. Unzip it +and add mysql address configuration in `conf/application.properties` + +```properties +spring.jpa.hibernate.ddl-auto=update +# configuration for manager +spring.datasource.url=jdbc:mysql://x.x.x.x:3306/tubemanager +spring.datasource.username=xx +spring.datasource.password=xxx +``` +Then setup mysql database called `tubemanager`, start this project by this command +```shell script +bin/start-manager.sh +```