Browse Source
Admin UI: Add message queue to admin UI (#6958)
Admin UI: Add message queue to admin UI (#6958)
* add a menu item "Message Queue" * add a menu item "Message Queue" * move the "brokers" link under it. * add "topics", "subscribers". Add pages for them. * refactor * show topic details * admin display publisher and subscriber info * remove publisher and subscribers from the topic row pull down * collecting more stats from publishers and subscribers * fix layout * fix publisher name * add local listeners for mq broker and agent * render consumer group offsets * remove subscribers from left menu * topic with retention * support editing topic retention * show retention when listing topics * create bucket * Update s3_buckets_templ.go * embed the static assets into the binary fix https://github.com/seaweedfs/seaweedfs/issues/6964pull/6973/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 8345 additions and 1205 deletions
-
5.gitignore
-
228test/mq/Makefile
-
244test/mq/README.md
-
192test/mq/consumer/main.go
-
172test/mq/producer/main.go
-
47weed/admin/dash/admin_data.go
-
279weed/admin/dash/admin_server.go
-
69weed/admin/dash/bucket_management.go
-
615weed/admin/dash/mq_management.go
-
296weed/admin/dash/topic_retention.go
-
130weed/admin/dash/types.go
-
31weed/admin/handlers/admin_handlers.go
-
27weed/admin/handlers/cluster_handlers.go
-
238weed/admin/handlers/mq_handlers.go
-
14weed/admin/static_embed.go
-
12weed/admin/view/app/admin.templ
-
149weed/admin/view/app/admin_templ.go
-
144weed/admin/view/app/cluster_brokers.templ
-
168weed/admin/view/app/cluster_brokers_templ.go
-
272weed/admin/view/app/s3_buckets.templ
-
130weed/admin/view/app/s3_buckets_templ.go
-
151weed/admin/view/app/subscribers.templ
-
246weed/admin/view/app/subscribers_templ.go
-
677weed/admin/view/app/topic_details.templ
-
949weed/admin/view/app/topic_details_templ.go
-
511weed/admin/view/app/topics.templ
-
230weed/admin/view/app/topics_templ.go
-
61weed/admin/view/layout/layout.templ
-
161weed/admin/view/layout/layout_templ.go
-
11weed/command/admin.go
-
20weed/command/mq_agent.go
-
19weed/command/mq_broker.go
-
15weed/filer_client/filer_client_accessor.go
-
1weed/mq/broker/broker_grpc_configure.go
-
258weed/mq/broker/broker_grpc_lookup.go
-
23weed/mq/broker/broker_grpc_pub.go
-
14weed/mq/broker/broker_grpc_sub.go
-
67weed/mq/topic/local_partition_publishers.go
-
72weed/mq/topic/local_partition_subscribers.go
-
34weed/mq/topic/topic.go
-
64weed/pb/mq_broker.proto
-
1328weed/pb/mq_pb/mq_broker.pb.go
-
114weed/pb/mq_pb/mq_broker_grpc.pb.go
-
740weed/pb/worker_pb/worker.pb.go
@ -0,0 +1,228 @@ |
|||||
|
# SeaweedFS Message Queue Test Makefile
|
||||
|
|
||||
|
# Build configuration
|
||||
|
GO_BUILD_CMD=go build -o bin/$(1) $(2) |
||||
|
GO_RUN_CMD=go run $(1) $(2) |
||||
|
|
||||
|
# Default values
|
||||
|
AGENT_ADDR?=localhost:16777 |
||||
|
TOPIC_NAMESPACE?=test |
||||
|
TOPIC_NAME?=test-topic |
||||
|
PARTITION_COUNT?=4 |
||||
|
MESSAGE_COUNT?=100 |
||||
|
CONSUMER_GROUP?=test-consumer-group |
||||
|
CONSUMER_INSTANCE?=test-consumer-1 |
||||
|
|
||||
|
# Create bin directory
|
||||
|
$(shell mkdir -p bin) |
||||
|
|
||||
|
.PHONY: all build clean producer consumer test help |
||||
|
|
||||
|
all: build |
||||
|
|
||||
|
# Build targets
|
||||
|
build: build-producer build-consumer |
||||
|
|
||||
|
build-producer: |
||||
|
@echo "Building producer..." |
||||
|
$(call GO_BUILD_CMD,producer,./producer) |
||||
|
|
||||
|
build-consumer: |
||||
|
@echo "Building consumer..." |
||||
|
$(call GO_BUILD_CMD,consumer,./consumer) |
||||
|
|
||||
|
# Run targets
|
||||
|
producer: build-producer |
||||
|
@echo "Starting producer..." |
||||
|
./bin/producer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=$(TOPIC_NAME) \
|
||||
|
-partitions=$(PARTITION_COUNT) \
|
||||
|
-messages=$(MESSAGE_COUNT) \
|
||||
|
-publisher=test-producer \
|
||||
|
-size=1024 \
|
||||
|
-interval=100ms |
||||
|
|
||||
|
consumer: build-consumer |
||||
|
@echo "Starting consumer..." |
||||
|
./bin/consumer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=$(TOPIC_NAME) \
|
||||
|
-group=$(CONSUMER_GROUP) \
|
||||
|
-instance=$(CONSUMER_INSTANCE) \
|
||||
|
-max-partitions=10 \
|
||||
|
-window-size=100 \
|
||||
|
-offset=latest \
|
||||
|
-show-messages=true \
|
||||
|
-log-progress=true |
||||
|
|
||||
|
# Run producer directly with go run
|
||||
|
run-producer: |
||||
|
@echo "Running producer directly..." |
||||
|
$(call GO_RUN_CMD,./producer, \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=$(TOPIC_NAME) \
|
||||
|
-partitions=$(PARTITION_COUNT) \
|
||||
|
-messages=$(MESSAGE_COUNT) \
|
||||
|
-publisher=test-producer \
|
||||
|
-size=1024 \
|
||||
|
-interval=100ms) |
||||
|
|
||||
|
# Run consumer directly with go run
|
||||
|
run-consumer: |
||||
|
@echo "Running consumer directly..." |
||||
|
$(call GO_RUN_CMD,./consumer, \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=$(TOPIC_NAME) \
|
||||
|
-group=$(CONSUMER_GROUP) \
|
||||
|
-instance=$(CONSUMER_INSTANCE) \
|
||||
|
-max-partitions=10 \
|
||||
|
-window-size=100 \
|
||||
|
-offset=latest \
|
||||
|
-show-messages=true \
|
||||
|
-log-progress=true) |
||||
|
|
||||
|
# Test scenarios
|
||||
|
test: test-basic |
||||
|
|
||||
|
test-basic: build |
||||
|
@echo "Running basic producer/consumer test..." |
||||
|
@echo "1. Starting consumer in background..." |
||||
|
./bin/consumer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=$(TOPIC_NAME) \
|
||||
|
-group=$(CONSUMER_GROUP) \
|
||||
|
-instance=$(CONSUMER_INSTANCE) \
|
||||
|
-offset=earliest \
|
||||
|
-show-messages=false \
|
||||
|
-log-progress=true & \
|
||||
|
CONSUMER_PID=$$!; \
|
||||
|
echo "Consumer PID: $$CONSUMER_PID"; \
|
||||
|
sleep 2; \
|
||||
|
echo "2. Starting producer..."; \
|
||||
|
./bin/producer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=$(TOPIC_NAME) \
|
||||
|
-partitions=$(PARTITION_COUNT) \
|
||||
|
-messages=$(MESSAGE_COUNT) \
|
||||
|
-publisher=test-producer \
|
||||
|
-size=1024 \
|
||||
|
-interval=50ms; \
|
||||
|
echo "3. Waiting for consumer to process messages..."; \
|
||||
|
sleep 5; \
|
||||
|
echo "4. Stopping consumer..."; \
|
||||
|
kill $$CONSUMER_PID || true; \
|
||||
|
echo "Test completed!" |
||||
|
|
||||
|
test-performance: build |
||||
|
@echo "Running performance test..." |
||||
|
@echo "1. Starting consumer in background..." |
||||
|
./bin/consumer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=perf-test \
|
||||
|
-group=perf-consumer-group \
|
||||
|
-instance=perf-consumer-1 \
|
||||
|
-offset=earliest \
|
||||
|
-show-messages=false \
|
||||
|
-log-progress=true & \
|
||||
|
CONSUMER_PID=$$!; \
|
||||
|
echo "Consumer PID: $$CONSUMER_PID"; \
|
||||
|
sleep 2; \
|
||||
|
echo "2. Starting high-throughput producer..."; \
|
||||
|
./bin/producer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=perf-test \
|
||||
|
-partitions=8 \
|
||||
|
-messages=1000 \
|
||||
|
-publisher=perf-producer \
|
||||
|
-size=512 \
|
||||
|
-interval=10ms; \
|
||||
|
echo "3. Waiting for consumer to process messages..."; \
|
||||
|
sleep 10; \
|
||||
|
echo "4. Stopping consumer..."; \
|
||||
|
kill $$CONSUMER_PID || true; \
|
||||
|
echo "Performance test completed!" |
||||
|
|
||||
|
test-multiple-consumers: build |
||||
|
@echo "Running multiple consumers test..." |
||||
|
@echo "1. Starting multiple consumers in background..." |
||||
|
./bin/consumer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=multi-test \
|
||||
|
-group=multi-consumer-group \
|
||||
|
-instance=consumer-1 \
|
||||
|
-offset=earliest \
|
||||
|
-show-messages=false \
|
||||
|
-log-progress=true & \
|
||||
|
CONSUMER1_PID=$$!; \
|
||||
|
./bin/consumer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=multi-test \
|
||||
|
-group=multi-consumer-group \
|
||||
|
-instance=consumer-2 \
|
||||
|
-offset=earliest \
|
||||
|
-show-messages=false \
|
||||
|
-log-progress=true & \
|
||||
|
CONSUMER2_PID=$$!; \
|
||||
|
echo "Consumer PIDs: $$CONSUMER1_PID, $$CONSUMER2_PID"; \
|
||||
|
sleep 2; \
|
||||
|
echo "2. Starting producer..."; \
|
||||
|
./bin/producer \
|
||||
|
-agent=$(AGENT_ADDR) \
|
||||
|
-namespace=$(TOPIC_NAMESPACE) \
|
||||
|
-topic=multi-test \
|
||||
|
-partitions=8 \
|
||||
|
-messages=200 \
|
||||
|
-publisher=multi-producer \
|
||||
|
-size=256 \
|
||||
|
-interval=50ms; \
|
||||
|
echo "3. Waiting for consumers to process messages..."; \
|
||||
|
sleep 10; \
|
||||
|
echo "4. Stopping consumers..."; \
|
||||
|
kill $$CONSUMER1_PID $$CONSUMER2_PID || true; \
|
||||
|
echo "Multiple consumers test completed!" |
||||
|
|
||||
|
# Clean up
|
||||
|
clean: |
||||
|
@echo "Cleaning up..." |
||||
|
rm -rf bin/ |
||||
|
go clean -cache |
||||
|
|
||||
|
# Help
|
||||
|
help: |
||||
|
@echo "SeaweedFS Message Queue Test Makefile" |
||||
|
@echo "" |
||||
|
@echo "Usage:" |
||||
|
@echo " make build - Build producer and consumer binaries" |
||||
|
@echo " make producer - Run producer (builds first)" |
||||
|
@echo " make consumer - Run consumer (builds first)" |
||||
|
@echo " make run-producer - Run producer directly with go run" |
||||
|
@echo " make run-consumer - Run consumer directly with go run" |
||||
|
@echo " make test - Run basic producer/consumer test" |
||||
|
@echo " make test-performance - Run performance test" |
||||
|
@echo " make test-multiple-consumers - Run multiple consumers test" |
||||
|
@echo " make clean - Clean up build artifacts" |
||||
|
@echo "" |
||||
|
@echo "Configuration (set via environment variables):" |
||||
|
@echo " AGENT_ADDR=10.21.152.113:16777 - MQ agent address" |
||||
|
@echo " TOPIC_NAMESPACE=test - Topic namespace" |
||||
|
@echo " TOPIC_NAME=test-topic - Topic name" |
||||
|
@echo " PARTITION_COUNT=4 - Number of partitions" |
||||
|
@echo " MESSAGE_COUNT=100 - Number of messages to produce" |
||||
|
@echo " CONSUMER_GROUP=test-consumer-group - Consumer group name" |
||||
|
@echo " CONSUMER_INSTANCE=test-consumer-1 - Consumer instance ID" |
||||
|
@echo "" |
||||
|
@echo "Examples:" |
||||
|
@echo " make producer MESSAGE_COUNT=1000 PARTITION_COUNT=8" |
||||
|
@echo " make consumer CONSUMER_GROUP=my-group" |
||||
|
@echo " make test AGENT_ADDR=10.21.152.113:16777 MESSAGE_COUNT=500" |
||||
@ -0,0 +1,244 @@ |
|||||
|
# SeaweedFS Message Queue Test Suite |
||||
|
|
||||
|
This directory contains test programs for SeaweedFS Message Queue (MQ) functionality, including message producers and consumers. |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
1. **SeaweedFS with MQ Broker and Agent**: You need a running SeaweedFS instance with MQ broker and agent enabled |
||||
|
2. **Go**: Go 1.19 or later required for building the test programs |
||||
|
|
||||
|
## Quick Start |
||||
|
|
||||
|
### 1. Start SeaweedFS with MQ Broker and Agent |
||||
|
|
||||
|
```bash |
||||
|
# Start SeaweedFS server with MQ broker and agent |
||||
|
weed server -mq.broker -mq.agent -filer -volume |
||||
|
|
||||
|
# Or start components separately |
||||
|
weed master |
||||
|
weed volume -mserver=localhost:9333 |
||||
|
weed filer -master=localhost:9333 |
||||
|
weed mq.broker -filer=localhost:8888 |
||||
|
weed mq.agent -brokers=localhost:17777 |
||||
|
``` |
||||
|
|
||||
|
### 2. Build Test Programs |
||||
|
|
||||
|
```bash |
||||
|
# Build both producer and consumer |
||||
|
make build |
||||
|
|
||||
|
# Or build individually |
||||
|
make build-producer |
||||
|
make build-consumer |
||||
|
``` |
||||
|
|
||||
|
### 3. Run Basic Test |
||||
|
|
||||
|
```bash |
||||
|
# Run a basic producer/consumer test |
||||
|
make test |
||||
|
|
||||
|
# Or run producer and consumer manually |
||||
|
make consumer & # Start consumer in background |
||||
|
make producer # Start producer |
||||
|
``` |
||||
|
|
||||
|
## Test Programs |
||||
|
|
||||
|
### Producer (`producer/main.go`) |
||||
|
|
||||
|
Generates structured messages and publishes them to a SeaweedMQ topic via the MQ agent. |
||||
|
|
||||
|
**Usage:** |
||||
|
```bash |
||||
|
./bin/producer [options] |
||||
|
``` |
||||
|
|
||||
|
**Options:** |
||||
|
- `-agent`: MQ agent address (default: localhost:16777) |
||||
|
- `-namespace`: Topic namespace (default: test) |
||||
|
- `-topic`: Topic name (default: test-topic) |
||||
|
- `-partitions`: Number of partitions (default: 4) |
||||
|
- `-messages`: Number of messages to produce (default: 100) |
||||
|
- `-publisher`: Publisher name (default: test-producer) |
||||
|
- `-size`: Message size in bytes (default: 1024) |
||||
|
- `-interval`: Interval between messages (default: 100ms) |
||||
|
|
||||
|
**Example:** |
||||
|
```bash |
||||
|
./bin/producer -agent=localhost:16777 -namespace=test -topic=my-topic -messages=1000 -interval=50ms |
||||
|
``` |
||||
|
|
||||
|
### Consumer (`consumer/main.go`) |
||||
|
|
||||
|
Consumes structured messages from a SeaweedMQ topic via the MQ agent. |
||||
|
|
||||
|
**Usage:** |
||||
|
```bash |
||||
|
./bin/consumer [options] |
||||
|
``` |
||||
|
|
||||
|
**Options:** |
||||
|
- `-agent`: MQ agent address (default: localhost:16777) |
||||
|
- `-namespace`: Topic namespace (default: test) |
||||
|
- `-topic`: Topic name (default: test-topic) |
||||
|
- `-group`: Consumer group name (default: test-consumer-group) |
||||
|
- `-instance`: Consumer group instance ID (default: test-consumer-1) |
||||
|
- `-max-partitions`: Maximum number of partitions to consume (default: 10) |
||||
|
- `-window-size`: Sliding window size for concurrent processing (default: 100) |
||||
|
- `-offset`: Offset type: earliest, latest, timestamp (default: latest) |
||||
|
- `-offset-ts`: Offset timestamp in nanoseconds (for timestamp offset type) |
||||
|
- `-filter`: Message filter (default: empty) |
||||
|
- `-show-messages`: Show consumed messages (default: true) |
||||
|
- `-log-progress`: Log progress every 10 messages (default: true) |
||||
|
|
||||
|
**Example:** |
||||
|
```bash |
||||
|
./bin/consumer -agent=localhost:16777 -namespace=test -topic=my-topic -group=my-group -offset=earliest |
||||
|
``` |
||||
|
|
||||
|
## Makefile Commands |
||||
|
|
||||
|
### Building |
||||
|
- `make build`: Build both producer and consumer binaries |
||||
|
- `make build-producer`: Build producer only |
||||
|
- `make build-consumer`: Build consumer only |
||||
|
|
||||
|
### Running |
||||
|
- `make producer`: Build and run producer |
||||
|
- `make consumer`: Build and run consumer |
||||
|
- `make run-producer`: Run producer directly with go run |
||||
|
- `make run-consumer`: Run consumer directly with go run |
||||
|
|
||||
|
### Testing |
||||
|
- `make test`: Run basic producer/consumer test |
||||
|
- `make test-performance`: Run performance test (1000 messages, 8 partitions) |
||||
|
- `make test-multiple-consumers`: Run test with multiple consumers |
||||
|
|
||||
|
### Cleanup |
||||
|
- `make clean`: Remove build artifacts |
||||
|
|
||||
|
### Help |
||||
|
- `make help`: Show detailed help |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
Configure tests using environment variables: |
||||
|
|
||||
|
```bash |
||||
|
export AGENT_ADDR=localhost:16777 |
||||
|
export TOPIC_NAMESPACE=test |
||||
|
export TOPIC_NAME=test-topic |
||||
|
export PARTITION_COUNT=4 |
||||
|
export MESSAGE_COUNT=100 |
||||
|
export CONSUMER_GROUP=test-consumer-group |
||||
|
export CONSUMER_INSTANCE=test-consumer-1 |
||||
|
``` |
||||
|
|
||||
|
## Example Usage Scenarios |
||||
|
|
||||
|
### 1. Basic Producer/Consumer Test |
||||
|
|
||||
|
```bash |
||||
|
# Terminal 1: Start consumer |
||||
|
make consumer |
||||
|
|
||||
|
# Terminal 2: Run producer |
||||
|
make producer MESSAGE_COUNT=50 |
||||
|
``` |
||||
|
|
||||
|
### 2. Performance Testing |
||||
|
|
||||
|
```bash |
||||
|
# Test with high throughput |
||||
|
make test-performance |
||||
|
``` |
||||
|
|
||||
|
### 3. Multiple Consumer Groups |
||||
|
|
||||
|
```bash |
||||
|
# Terminal 1: Consumer group 1 |
||||
|
make consumer CONSUMER_GROUP=group1 |
||||
|
|
||||
|
# Terminal 2: Consumer group 2 |
||||
|
make consumer CONSUMER_GROUP=group2 |
||||
|
|
||||
|
# Terminal 3: Producer |
||||
|
make producer MESSAGE_COUNT=200 |
||||
|
``` |
||||
|
|
||||
|
### 4. Different Offset Types |
||||
|
|
||||
|
```bash |
||||
|
# Consume from earliest |
||||
|
make consumer OFFSET=earliest |
||||
|
|
||||
|
# Consume from latest |
||||
|
make consumer OFFSET=latest |
||||
|
|
||||
|
# Consume from timestamp |
||||
|
make consumer OFFSET=timestamp OFFSET_TS=1699000000000000000 |
||||
|
``` |
||||
|
|
||||
|
## Troubleshooting |
||||
|
|
||||
|
### Common Issues |
||||
|
|
||||
|
1. **Connection Refused**: Make sure SeaweedFS MQ agent is running on the specified address |
||||
|
2. **Agent Not Found**: Ensure both MQ broker and agent are running (agent requires broker) |
||||
|
3. **Topic Not Found**: The producer will create the topic automatically on first publish |
||||
|
4. **Consumer Not Receiving Messages**: Check if consumer group offset is correct (try `earliest`) |
||||
|
5. **Build Failures**: Ensure you're running from the SeaweedFS root directory |
||||
|
|
||||
|
### Debug Mode |
||||
|
|
||||
|
Enable verbose logging: |
||||
|
```bash |
||||
|
# Run with debug logging |
||||
|
GLOG_v=4 make producer |
||||
|
GLOG_v=4 make consumer |
||||
|
``` |
||||
|
|
||||
|
### Check Broker and Agent Status |
||||
|
|
||||
|
```bash |
||||
|
# Check if broker is running |
||||
|
curl http://localhost:9333/cluster/brokers |
||||
|
|
||||
|
# Check if agent is running (if running as server) |
||||
|
curl http://localhost:9333/cluster/agents |
||||
|
|
||||
|
# Or use weed shell |
||||
|
weed shell -master=localhost:9333 |
||||
|
> mq.broker.list |
||||
|
``` |
||||
|
|
||||
|
## Architecture |
||||
|
|
||||
|
The test setup demonstrates: |
||||
|
|
||||
|
1. **Agent-Based Architecture**: Uses MQ agent as intermediary between clients and brokers |
||||
|
2. **Structured Messages**: Messages use schema-based RecordValue format instead of raw bytes |
||||
|
3. **Topic Management**: Creating and configuring topics with multiple partitions |
||||
|
4. **Message Production**: Publishing structured messages with keys for partitioning |
||||
|
5. **Message Consumption**: Consuming structured messages with consumer groups and offset management |
||||
|
6. **Load Balancing**: Multiple consumers in same group share partition assignments |
||||
|
7. **Fault Tolerance**: Graceful handling of agent and broker failures and reconnections |
||||
|
|
||||
|
## Files |
||||
|
|
||||
|
- `producer/main.go`: Message producer implementation |
||||
|
- `consumer/main.go`: Message consumer implementation |
||||
|
- `Makefile`: Build and test automation |
||||
|
- `README.md`: This documentation |
||||
|
- `bin/`: Built binaries (created during build) |
||||
|
|
||||
|
## Next Steps |
||||
|
|
||||
|
1. Modify the producer to send structured data using `RecordType` |
||||
|
2. Implement message filtering in the consumer |
||||
|
3. Add metrics collection and monitoring |
||||
|
4. Test with multiple broker instances |
||||
|
5. Implement schema evolution testing |
||||
@ -0,0 +1,192 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"flag" |
||||
|
"fmt" |
||||
|
"log" |
||||
|
"os" |
||||
|
"os/signal" |
||||
|
"sync" |
||||
|
"syscall" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/mq/client/agent_client" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/mq/topic" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" |
||||
|
) |
||||
|
|
||||
|
var ( |
||||
|
agentAddr = flag.String("agent", "localhost:16777", "MQ agent address") |
||||
|
topicNamespace = flag.String("namespace", "test", "topic namespace") |
||||
|
topicName = flag.String("topic", "test-topic", "topic name") |
||||
|
consumerGroup = flag.String("group", "test-consumer-group", "consumer group name") |
||||
|
consumerGroupInstanceId = flag.String("instance", "test-consumer-1", "consumer group instance id") |
||||
|
maxPartitions = flag.Int("max-partitions", 10, "maximum number of partitions to consume") |
||||
|
slidingWindowSize = flag.Int("window-size", 100, "sliding window size for concurrent processing") |
||||
|
offsetType = flag.String("offset", "latest", "offset type: earliest, latest, timestamp") |
||||
|
offsetTsNs = flag.Int64("offset-ts", 0, "offset timestamp in nanoseconds (for timestamp offset type)") |
||||
|
showMessages = flag.Bool("show-messages", true, "show consumed messages") |
||||
|
logProgress = flag.Bool("log-progress", true, "log progress every 10 messages") |
||||
|
filter = flag.String("filter", "", "message filter") |
||||
|
) |
||||
|
|
||||
|
func main() { |
||||
|
flag.Parse() |
||||
|
|
||||
|
fmt.Printf("Starting message consumer:\n") |
||||
|
fmt.Printf(" Agent: %s\n", *agentAddr) |
||||
|
fmt.Printf(" Topic: %s.%s\n", *topicNamespace, *topicName) |
||||
|
fmt.Printf(" Consumer Group: %s\n", *consumerGroup) |
||||
|
fmt.Printf(" Consumer Instance: %s\n", *consumerGroupInstanceId) |
||||
|
fmt.Printf(" Max Partitions: %d\n", *maxPartitions) |
||||
|
fmt.Printf(" Sliding Window Size: %d\n", *slidingWindowSize) |
||||
|
fmt.Printf(" Offset Type: %s\n", *offsetType) |
||||
|
fmt.Printf(" Filter: %s\n", *filter) |
||||
|
|
||||
|
// Create topic
|
||||
|
topicObj := topic.NewTopic(*topicNamespace, *topicName) |
||||
|
|
||||
|
// Determine offset type
|
||||
|
var pbOffsetType schema_pb.OffsetType |
||||
|
switch *offsetType { |
||||
|
case "earliest": |
||||
|
pbOffsetType = schema_pb.OffsetType_RESET_TO_EARLIEST |
||||
|
case "latest": |
||||
|
pbOffsetType = schema_pb.OffsetType_RESET_TO_LATEST |
||||
|
case "timestamp": |
||||
|
pbOffsetType = schema_pb.OffsetType_EXACT_TS_NS |
||||
|
default: |
||||
|
pbOffsetType = schema_pb.OffsetType_RESET_TO_LATEST |
||||
|
} |
||||
|
|
||||
|
// Create subscribe option
|
||||
|
option := &agent_client.SubscribeOption{ |
||||
|
ConsumerGroup: *consumerGroup, |
||||
|
ConsumerGroupInstanceId: *consumerGroupInstanceId, |
||||
|
Topic: topicObj, |
||||
|
OffsetType: pbOffsetType, |
||||
|
OffsetTsNs: *offsetTsNs, |
||||
|
Filter: *filter, |
||||
|
MaxSubscribedPartitions: int32(*maxPartitions), |
||||
|
SlidingWindowSize: int32(*slidingWindowSize), |
||||
|
} |
||||
|
|
||||
|
// Create subscribe session
|
||||
|
session, err := agent_client.NewSubscribeSession(*agentAddr, option) |
||||
|
if err != nil { |
||||
|
log.Fatalf("Failed to create subscribe session: %v", err) |
||||
|
} |
||||
|
defer session.CloseSession() |
||||
|
|
||||
|
// Statistics
|
||||
|
var messageCount int64 |
||||
|
var mu sync.Mutex |
||||
|
startTime := time.Now() |
||||
|
|
||||
|
// Handle graceful shutdown
|
||||
|
sigChan := make(chan os.Signal, 1) |
||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) |
||||
|
|
||||
|
// Channel to signal completion
|
||||
|
done := make(chan error, 1) |
||||
|
|
||||
|
// Start consuming messages
|
||||
|
fmt.Printf("\nStarting to consume messages...\n") |
||||
|
go func() { |
||||
|
err := session.SubscribeMessageRecord( |
||||
|
// onEachMessageFn
|
||||
|
func(key []byte, record *schema_pb.RecordValue) { |
||||
|
mu.Lock() |
||||
|
messageCount++ |
||||
|
currentCount := messageCount |
||||
|
mu.Unlock() |
||||
|
|
||||
|
if *showMessages { |
||||
|
fmt.Printf("Received message: key=%s\n", string(key)) |
||||
|
printRecordValue(record) |
||||
|
} |
||||
|
|
||||
|
if *logProgress && currentCount%10 == 0 { |
||||
|
elapsed := time.Since(startTime) |
||||
|
rate := float64(currentCount) / elapsed.Seconds() |
||||
|
fmt.Printf("Consumed %d messages (%.2f msg/sec)\n", currentCount, rate) |
||||
|
} |
||||
|
}, |
||||
|
// onCompletionFn
|
||||
|
func() { |
||||
|
fmt.Printf("Subscription completed\n") |
||||
|
done <- nil |
||||
|
}, |
||||
|
) |
||||
|
if err != nil { |
||||
|
done <- err |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
// Wait for signal or completion
|
||||
|
select { |
||||
|
case <-sigChan: |
||||
|
fmt.Printf("\nReceived shutdown signal, stopping consumer...\n") |
||||
|
case err := <-done: |
||||
|
if err != nil { |
||||
|
log.Printf("Subscription error: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Print final statistics
|
||||
|
mu.Lock() |
||||
|
finalCount := messageCount |
||||
|
mu.Unlock() |
||||
|
|
||||
|
duration := time.Since(startTime) |
||||
|
fmt.Printf("Consumed %d messages in %v\n", finalCount, duration) |
||||
|
if duration.Seconds() > 0 { |
||||
|
fmt.Printf("Average throughput: %.2f messages/sec\n", float64(finalCount)/duration.Seconds()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func printRecordValue(record *schema_pb.RecordValue) { |
||||
|
if record == nil || record.Fields == nil { |
||||
|
fmt.Printf(" (empty record)\n") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
for fieldName, value := range record.Fields { |
||||
|
fmt.Printf(" %s: %s\n", fieldName, formatValue(value)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func formatValue(value *schema_pb.Value) string { |
||||
|
if value == nil { |
||||
|
return "(nil)" |
||||
|
} |
||||
|
|
||||
|
switch kind := value.Kind.(type) { |
||||
|
case *schema_pb.Value_BoolValue: |
||||
|
return fmt.Sprintf("%t", kind.BoolValue) |
||||
|
case *schema_pb.Value_Int32Value: |
||||
|
return fmt.Sprintf("%d", kind.Int32Value) |
||||
|
case *schema_pb.Value_Int64Value: |
||||
|
return fmt.Sprintf("%d", kind.Int64Value) |
||||
|
case *schema_pb.Value_FloatValue: |
||||
|
return fmt.Sprintf("%f", kind.FloatValue) |
||||
|
case *schema_pb.Value_DoubleValue: |
||||
|
return fmt.Sprintf("%f", kind.DoubleValue) |
||||
|
case *schema_pb.Value_BytesValue: |
||||
|
if len(kind.BytesValue) > 50 { |
||||
|
return fmt.Sprintf("bytes[%d] %x...", len(kind.BytesValue), kind.BytesValue[:50]) |
||||
|
} |
||||
|
return fmt.Sprintf("bytes[%d] %x", len(kind.BytesValue), kind.BytesValue) |
||||
|
case *schema_pb.Value_StringValue: |
||||
|
if len(kind.StringValue) > 100 { |
||||
|
return fmt.Sprintf("\"%s...\"", kind.StringValue[:100]) |
||||
|
} |
||||
|
return fmt.Sprintf("\"%s\"", kind.StringValue) |
||||
|
case *schema_pb.Value_ListValue: |
||||
|
return fmt.Sprintf("list[%d items]", len(kind.ListValue.Values)) |
||||
|
case *schema_pb.Value_RecordValue: |
||||
|
return fmt.Sprintf("record[%d fields]", len(kind.RecordValue.Fields)) |
||||
|
default: |
||||
|
return "(unknown)" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,172 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"flag" |
||||
|
"fmt" |
||||
|
"log" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/mq/client/agent_client" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/mq/schema" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" |
||||
|
) |
||||
|
|
||||
|
var ( |
||||
|
agentAddr = flag.String("agent", "localhost:16777", "MQ agent address") |
||||
|
topicNamespace = flag.String("namespace", "test", "topic namespace") |
||||
|
topicName = flag.String("topic", "test-topic", "topic name") |
||||
|
partitionCount = flag.Int("partitions", 4, "number of partitions") |
||||
|
messageCount = flag.Int("messages", 100, "number of messages to produce") |
||||
|
publisherName = flag.String("publisher", "test-producer", "publisher name") |
||||
|
messageSize = flag.Int("size", 1024, "message size in bytes") |
||||
|
interval = flag.Duration("interval", 100*time.Millisecond, "interval between messages") |
||||
|
) |
||||
|
|
||||
|
// TestMessage represents the structure of messages we'll be sending
|
||||
|
type TestMessage struct { |
||||
|
ID int64 `json:"id"` |
||||
|
Message string `json:"message"` |
||||
|
Payload []byte `json:"payload"` |
||||
|
Timestamp int64 `json:"timestamp"` |
||||
|
} |
||||
|
|
||||
|
func main() { |
||||
|
flag.Parse() |
||||
|
|
||||
|
fmt.Printf("Starting message producer:\n") |
||||
|
fmt.Printf(" Agent: %s\n", *agentAddr) |
||||
|
fmt.Printf(" Topic: %s.%s\n", *topicNamespace, *topicName) |
||||
|
fmt.Printf(" Partitions: %d\n", *partitionCount) |
||||
|
fmt.Printf(" Messages: %d\n", *messageCount) |
||||
|
fmt.Printf(" Publisher: %s\n", *publisherName) |
||||
|
fmt.Printf(" Message Size: %d bytes\n", *messageSize) |
||||
|
fmt.Printf(" Interval: %v\n", *interval) |
||||
|
|
||||
|
// Create an instance of the message struct to generate schema from
|
||||
|
messageInstance := TestMessage{} |
||||
|
|
||||
|
// Automatically generate RecordType from the struct
|
||||
|
recordType := schema.StructToSchema(messageInstance) |
||||
|
if recordType == nil { |
||||
|
log.Fatalf("Failed to generate schema from struct") |
||||
|
} |
||||
|
|
||||
|
fmt.Printf("\nGenerated schema with %d fields:\n", len(recordType.Fields)) |
||||
|
for _, field := range recordType.Fields { |
||||
|
fmt.Printf(" - %s: %s\n", field.Name, getTypeString(field.Type)) |
||||
|
} |
||||
|
|
||||
|
topicSchema := schema.NewSchema(*topicNamespace, *topicName, recordType) |
||||
|
|
||||
|
// Create publish session
|
||||
|
session, err := agent_client.NewPublishSession(*agentAddr, topicSchema, *partitionCount, *publisherName) |
||||
|
if err != nil { |
||||
|
log.Fatalf("Failed to create publish session: %v", err) |
||||
|
} |
||||
|
defer session.CloseSession() |
||||
|
|
||||
|
// Create message payload
|
||||
|
payload := make([]byte, *messageSize) |
||||
|
for i := range payload { |
||||
|
payload[i] = byte(i % 256) |
||||
|
} |
||||
|
|
||||
|
// Start producing messages
|
||||
|
fmt.Printf("\nStarting to produce messages...\n") |
||||
|
startTime := time.Now() |
||||
|
|
||||
|
for i := 0; i < *messageCount; i++ { |
||||
|
key := fmt.Sprintf("key-%d", i) |
||||
|
|
||||
|
// Create a message struct
|
||||
|
message := TestMessage{ |
||||
|
ID: int64(i), |
||||
|
Message: fmt.Sprintf("This is message number %d", i), |
||||
|
Payload: payload[:min(100, len(payload))], // First 100 bytes
|
||||
|
Timestamp: time.Now().UnixNano(), |
||||
|
} |
||||
|
|
||||
|
// Convert struct to RecordValue
|
||||
|
record := structToRecordValue(message) |
||||
|
|
||||
|
err := session.PublishMessageRecord([]byte(key), record) |
||||
|
if err != nil { |
||||
|
log.Printf("Failed to publish message %d: %v", i, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
if (i+1)%10 == 0 { |
||||
|
fmt.Printf("Published %d messages\n", i+1) |
||||
|
} |
||||
|
|
||||
|
if *interval > 0 { |
||||
|
time.Sleep(*interval) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
duration := time.Since(startTime) |
||||
|
fmt.Printf("\nCompleted producing %d messages in %v\n", *messageCount, duration) |
||||
|
fmt.Printf("Throughput: %.2f messages/sec\n", float64(*messageCount)/duration.Seconds()) |
||||
|
} |
||||
|
|
||||
|
// Helper function to convert struct to RecordValue
|
||||
|
func structToRecordValue(msg TestMessage) *schema_pb.RecordValue { |
||||
|
return &schema_pb.RecordValue{ |
||||
|
Fields: map[string]*schema_pb.Value{ |
||||
|
"ID": { |
||||
|
Kind: &schema_pb.Value_Int64Value{ |
||||
|
Int64Value: msg.ID, |
||||
|
}, |
||||
|
}, |
||||
|
"Message": { |
||||
|
Kind: &schema_pb.Value_StringValue{ |
||||
|
StringValue: msg.Message, |
||||
|
}, |
||||
|
}, |
||||
|
"Payload": { |
||||
|
Kind: &schema_pb.Value_BytesValue{ |
||||
|
BytesValue: msg.Payload, |
||||
|
}, |
||||
|
}, |
||||
|
"Timestamp": { |
||||
|
Kind: &schema_pb.Value_Int64Value{ |
||||
|
Int64Value: msg.Timestamp, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func getTypeString(t *schema_pb.Type) string { |
||||
|
switch kind := t.Kind.(type) { |
||||
|
case *schema_pb.Type_ScalarType: |
||||
|
switch kind.ScalarType { |
||||
|
case schema_pb.ScalarType_BOOL: |
||||
|
return "bool" |
||||
|
case schema_pb.ScalarType_INT32: |
||||
|
return "int32" |
||||
|
case schema_pb.ScalarType_INT64: |
||||
|
return "int64" |
||||
|
case schema_pb.ScalarType_FLOAT: |
||||
|
return "float" |
||||
|
case schema_pb.ScalarType_DOUBLE: |
||||
|
return "double" |
||||
|
case schema_pb.ScalarType_BYTES: |
||||
|
return "bytes" |
||||
|
case schema_pb.ScalarType_STRING: |
||||
|
return "string" |
||||
|
} |
||||
|
case *schema_pb.Type_ListType: |
||||
|
return fmt.Sprintf("list<%s>", getTypeString(kind.ListType.ElementType)) |
||||
|
case *schema_pb.Type_RecordType: |
||||
|
return "record" |
||||
|
} |
||||
|
return "unknown" |
||||
|
} |
||||
|
|
||||
|
func min(a, b int) int { |
||||
|
if a < b { |
||||
|
return a |
||||
|
} |
||||
|
return b |
||||
|
} |
||||
@ -0,0 +1,615 @@ |
|||||
|
package dash |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/cluster" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/filer" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/mq/topic" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/util" |
||||
|
) |
||||
|
|
||||
|
// GetTopics retrieves message queue topics data
|
||||
|
func (s *AdminServer) GetTopics() (*TopicsData, error) { |
||||
|
var topics []TopicInfo |
||||
|
|
||||
|
// Find broker leader and get topics
|
||||
|
brokerLeader, err := s.findBrokerLeader() |
||||
|
if err != nil { |
||||
|
// If no broker leader found, return empty data
|
||||
|
return &TopicsData{ |
||||
|
Topics: topics, |
||||
|
TotalTopics: len(topics), |
||||
|
LastUpdated: time.Now(), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// Connect to broker leader and list topics
|
||||
|
err = s.withBrokerClient(brokerLeader, func(client mq_pb.SeaweedMessagingClient) error { |
||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
||||
|
defer cancel() |
||||
|
|
||||
|
resp, err := client.ListTopics(ctx, &mq_pb.ListTopicsRequest{}) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
// Convert protobuf topics to TopicInfo - only include available data
|
||||
|
for _, pbTopic := range resp.Topics { |
||||
|
topicInfo := TopicInfo{ |
||||
|
Name: fmt.Sprintf("%s.%s", pbTopic.Namespace, pbTopic.Name), |
||||
|
Partitions: 0, // Will be populated by LookupTopicBrokers call
|
||||
|
Retention: TopicRetentionInfo{ |
||||
|
Enabled: false, |
||||
|
DisplayValue: 0, |
||||
|
DisplayUnit: "days", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
// Get topic configuration to get partition count and retention info
|
||||
|
lookupResp, err := client.LookupTopicBrokers(ctx, &mq_pb.LookupTopicBrokersRequest{ |
||||
|
Topic: pbTopic, |
||||
|
}) |
||||
|
if err == nil { |
||||
|
topicInfo.Partitions = len(lookupResp.BrokerPartitionAssignments) |
||||
|
} |
||||
|
|
||||
|
// Get topic configuration for retention information
|
||||
|
configResp, err := client.GetTopicConfiguration(ctx, &mq_pb.GetTopicConfigurationRequest{ |
||||
|
Topic: pbTopic, |
||||
|
}) |
||||
|
if err == nil && configResp.Retention != nil { |
||||
|
topicInfo.Retention = convertTopicRetention(configResp.Retention) |
||||
|
} |
||||
|
|
||||
|
topics = append(topics, topicInfo) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
// If connection fails, return empty data
|
||||
|
return &TopicsData{ |
||||
|
Topics: topics, |
||||
|
TotalTopics: len(topics), |
||||
|
LastUpdated: time.Now(), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
return &TopicsData{ |
||||
|
Topics: topics, |
||||
|
TotalTopics: len(topics), |
||||
|
LastUpdated: time.Now(), |
||||
|
// Don't include TotalMessages and TotalSize as they're not available
|
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// GetSubscribers retrieves message queue subscribers data
|
||||
|
func (s *AdminServer) GetSubscribers() (*SubscribersData, error) { |
||||
|
var subscribers []SubscriberInfo |
||||
|
|
||||
|
// Find broker leader and get subscriber info from broker stats
|
||||
|
brokerLeader, err := s.findBrokerLeader() |
||||
|
if err != nil { |
||||
|
// If no broker leader found, return empty data
|
||||
|
return &SubscribersData{ |
||||
|
Subscribers: subscribers, |
||||
|
TotalSubscribers: len(subscribers), |
||||
|
ActiveSubscribers: 0, |
||||
|
LastUpdated: time.Now(), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// Connect to broker leader and get subscriber information
|
||||
|
// Note: SeaweedMQ doesn't have a direct API to list all subscribers
|
||||
|
// We would need to collect this information from broker statistics
|
||||
|
// For now, return empty data structure as subscriber info is not
|
||||
|
// directly available through the current MQ API
|
||||
|
err = s.withBrokerClient(brokerLeader, func(client mq_pb.SeaweedMessagingClient) error { |
||||
|
// TODO: Implement subscriber data collection from broker statistics
|
||||
|
// This would require access to broker internal statistics about
|
||||
|
// active subscribers, consumer groups, etc.
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
// If connection fails, return empty data
|
||||
|
return &SubscribersData{ |
||||
|
Subscribers: subscribers, |
||||
|
TotalSubscribers: len(subscribers), |
||||
|
ActiveSubscribers: 0, |
||||
|
LastUpdated: time.Now(), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
activeCount := 0 |
||||
|
for _, sub := range subscribers { |
||||
|
if sub.Status == "active" { |
||||
|
activeCount++ |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return &SubscribersData{ |
||||
|
Subscribers: subscribers, |
||||
|
TotalSubscribers: len(subscribers), |
||||
|
ActiveSubscribers: activeCount, |
||||
|
LastUpdated: time.Now(), |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
// GetTopicDetails retrieves detailed information about a specific topic
|
||||
|
func (s *AdminServer) GetTopicDetails(namespace, topicName string) (*TopicDetailsData, error) { |
||||
|
// Find broker leader
|
||||
|
brokerLeader, err := s.findBrokerLeader() |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to find broker leader: %v", err) |
||||
|
} |
||||
|
|
||||
|
var topicDetails *TopicDetailsData |
||||
|
|
||||
|
// Connect to broker leader and get topic configuration
|
||||
|
err = s.withBrokerClient(brokerLeader, func(client mq_pb.SeaweedMessagingClient) error { |
||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
||||
|
defer cancel() |
||||
|
|
||||
|
// Get topic configuration using the new API
|
||||
|
configResp, err := client.GetTopicConfiguration(ctx, &mq_pb.GetTopicConfigurationRequest{ |
||||
|
Topic: &schema_pb.Topic{ |
||||
|
Namespace: namespace, |
||||
|
Name: topicName, |
||||
|
}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get topic configuration: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Initialize topic details
|
||||
|
topicDetails = &TopicDetailsData{ |
||||
|
TopicName: fmt.Sprintf("%s.%s", namespace, topicName), |
||||
|
Namespace: namespace, |
||||
|
Name: topicName, |
||||
|
Partitions: []PartitionInfo{}, |
||||
|
Schema: []SchemaFieldInfo{}, |
||||
|
Publishers: []PublisherInfo{}, |
||||
|
Subscribers: []TopicSubscriberInfo{}, |
||||
|
ConsumerGroupOffsets: []ConsumerGroupOffsetInfo{}, |
||||
|
Retention: convertTopicRetention(configResp.Retention), |
||||
|
CreatedAt: time.Unix(0, configResp.CreatedAtNs), |
||||
|
LastUpdated: time.Unix(0, configResp.LastUpdatedNs), |
||||
|
} |
||||
|
|
||||
|
// Set current time if timestamps are not available
|
||||
|
if configResp.CreatedAtNs == 0 { |
||||
|
topicDetails.CreatedAt = time.Now() |
||||
|
} |
||||
|
if configResp.LastUpdatedNs == 0 { |
||||
|
topicDetails.LastUpdated = time.Now() |
||||
|
} |
||||
|
|
||||
|
// Process partitions
|
||||
|
for _, assignment := range configResp.BrokerPartitionAssignments { |
||||
|
if assignment.Partition != nil { |
||||
|
partitionInfo := PartitionInfo{ |
||||
|
ID: assignment.Partition.RangeStart, |
||||
|
LeaderBroker: assignment.LeaderBroker, |
||||
|
FollowerBroker: assignment.FollowerBroker, |
||||
|
MessageCount: 0, // Will be enhanced later with actual stats
|
||||
|
TotalSize: 0, // Will be enhanced later with actual stats
|
||||
|
LastDataTime: time.Time{}, // Will be enhanced later
|
||||
|
CreatedAt: time.Now(), |
||||
|
} |
||||
|
topicDetails.Partitions = append(topicDetails.Partitions, partitionInfo) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Process schema from RecordType
|
||||
|
if configResp.RecordType != nil { |
||||
|
topicDetails.Schema = convertRecordTypeToSchemaFields(configResp.RecordType) |
||||
|
} |
||||
|
|
||||
|
// Get publishers information
|
||||
|
publishersResp, err := client.GetTopicPublishers(ctx, &mq_pb.GetTopicPublishersRequest{ |
||||
|
Topic: &schema_pb.Topic{ |
||||
|
Namespace: namespace, |
||||
|
Name: topicName, |
||||
|
}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
// Log error but don't fail the entire request
|
||||
|
glog.V(0).Infof("failed to get topic publishers for %s.%s: %v", namespace, topicName, err) |
||||
|
} else { |
||||
|
glog.V(1).Infof("got %d publishers for topic %s.%s", len(publishersResp.Publishers), namespace, topicName) |
||||
|
topicDetails.Publishers = convertTopicPublishers(publishersResp.Publishers) |
||||
|
} |
||||
|
|
||||
|
// Get subscribers information
|
||||
|
subscribersResp, err := client.GetTopicSubscribers(ctx, &mq_pb.GetTopicSubscribersRequest{ |
||||
|
Topic: &schema_pb.Topic{ |
||||
|
Namespace: namespace, |
||||
|
Name: topicName, |
||||
|
}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
// Log error but don't fail the entire request
|
||||
|
glog.V(0).Infof("failed to get topic subscribers for %s.%s: %v", namespace, topicName, err) |
||||
|
} else { |
||||
|
glog.V(1).Infof("got %d subscribers for topic %s.%s", len(subscribersResp.Subscribers), namespace, topicName) |
||||
|
topicDetails.Subscribers = convertTopicSubscribers(subscribersResp.Subscribers) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Get consumer group offsets from the filer
|
||||
|
offsets, err := s.GetConsumerGroupOffsets(namespace, topicName) |
||||
|
if err != nil { |
||||
|
// Log error but don't fail the entire request
|
||||
|
glog.V(0).Infof("failed to get consumer group offsets for %s.%s: %v", namespace, topicName, err) |
||||
|
} else { |
||||
|
glog.V(1).Infof("got %d consumer group offsets for topic %s.%s", len(offsets), namespace, topicName) |
||||
|
topicDetails.ConsumerGroupOffsets = offsets |
||||
|
} |
||||
|
|
||||
|
return topicDetails, nil |
||||
|
} |
||||
|
|
||||
|
// GetConsumerGroupOffsets retrieves consumer group offsets for a topic from the filer
|
||||
|
func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]ConsumerGroupOffsetInfo, error) { |
||||
|
var offsets []ConsumerGroupOffsetInfo |
||||
|
|
||||
|
err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
||||
|
// Get the topic directory: /topics/namespace/topicName
|
||||
|
topicObj := topic.NewTopic(namespace, topicName) |
||||
|
topicDir := topicObj.Dir() |
||||
|
|
||||
|
// List all version directories under the topic directory (e.g., v2025-07-10-05-44-34)
|
||||
|
versionStream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ |
||||
|
Directory: topicDir, |
||||
|
Prefix: "", |
||||
|
StartFromFileName: "", |
||||
|
InclusiveStartFrom: false, |
||||
|
Limit: 1000, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to list topic directory %s: %v", topicDir, err) |
||||
|
} |
||||
|
|
||||
|
// Process each version directory
|
||||
|
for { |
||||
|
versionResp, err := versionStream.Recv() |
||||
|
if err != nil { |
||||
|
if err == io.EOF { |
||||
|
break |
||||
|
} |
||||
|
return fmt.Errorf("failed to receive version entries: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Only process directories that are versions (start with "v")
|
||||
|
if versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") { |
||||
|
versionDir := filepath.Join(topicDir, versionResp.Entry.Name) |
||||
|
|
||||
|
// List all partition directories under the version directory (e.g., 0315-0630)
|
||||
|
partitionStream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ |
||||
|
Directory: versionDir, |
||||
|
Prefix: "", |
||||
|
StartFromFileName: "", |
||||
|
InclusiveStartFrom: false, |
||||
|
Limit: 1000, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
glog.Warningf("Failed to list version directory %s: %v", versionDir, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
// Process each partition directory
|
||||
|
for { |
||||
|
partitionResp, err := partitionStream.Recv() |
||||
|
if err != nil { |
||||
|
if err == io.EOF { |
||||
|
break |
||||
|
} |
||||
|
glog.Warningf("Failed to receive partition entries: %v", err) |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
// Only process directories that are partitions (format: NNNN-NNNN)
|
||||
|
if partitionResp.Entry.IsDirectory { |
||||
|
// Parse partition range to get partition start ID (e.g., "0315-0630" -> 315)
|
||||
|
var partitionStart, partitionStop int32 |
||||
|
if n, err := fmt.Sscanf(partitionResp.Entry.Name, "%04d-%04d", &partitionStart, &partitionStop); n != 2 || err != nil { |
||||
|
// Skip directories that don't match the partition format
|
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
partitionDir := filepath.Join(versionDir, partitionResp.Entry.Name) |
||||
|
|
||||
|
// List all .offset files in this partition directory
|
||||
|
offsetStream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ |
||||
|
Directory: partitionDir, |
||||
|
Prefix: "", |
||||
|
StartFromFileName: "", |
||||
|
InclusiveStartFrom: false, |
||||
|
Limit: 1000, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
glog.Warningf("Failed to list partition directory %s: %v", partitionDir, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
// Process each offset file
|
||||
|
for { |
||||
|
offsetResp, err := offsetStream.Recv() |
||||
|
if err != nil { |
||||
|
if err == io.EOF { |
||||
|
break |
||||
|
} |
||||
|
glog.Warningf("Failed to receive offset entries: %v", err) |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
// Only process .offset files
|
||||
|
if !offsetResp.Entry.IsDirectory && strings.HasSuffix(offsetResp.Entry.Name, ".offset") { |
||||
|
consumerGroup := strings.TrimSuffix(offsetResp.Entry.Name, ".offset") |
||||
|
|
||||
|
// Read the offset value from the file
|
||||
|
offsetData, err := filer.ReadInsideFiler(client, partitionDir, offsetResp.Entry.Name) |
||||
|
if err != nil { |
||||
|
glog.Warningf("Failed to read offset file %s: %v", offsetResp.Entry.Name, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
if len(offsetData) == 8 { |
||||
|
offset := int64(util.BytesToUint64(offsetData)) |
||||
|
|
||||
|
// Get the file modification time
|
||||
|
lastUpdated := time.Unix(offsetResp.Entry.Attributes.Mtime, 0) |
||||
|
|
||||
|
offsets = append(offsets, ConsumerGroupOffsetInfo{ |
||||
|
ConsumerGroup: consumerGroup, |
||||
|
PartitionID: partitionStart, // Use partition start as the ID
|
||||
|
Offset: offset, |
||||
|
LastUpdated: lastUpdated, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to get consumer group offsets: %v", err) |
||||
|
} |
||||
|
|
||||
|
return offsets, nil |
||||
|
} |
||||
|
|
||||
|
// convertRecordTypeToSchemaFields converts a protobuf RecordType to SchemaFieldInfo slice
|
||||
|
func convertRecordTypeToSchemaFields(recordType *schema_pb.RecordType) []SchemaFieldInfo { |
||||
|
var schemaFields []SchemaFieldInfo |
||||
|
|
||||
|
if recordType == nil || recordType.Fields == nil { |
||||
|
return schemaFields |
||||
|
} |
||||
|
|
||||
|
for _, field := range recordType.Fields { |
||||
|
schemaField := SchemaFieldInfo{ |
||||
|
Name: field.Name, |
||||
|
Type: getFieldTypeString(field.Type), |
||||
|
Required: field.IsRequired, |
||||
|
} |
||||
|
schemaFields = append(schemaFields, schemaField) |
||||
|
} |
||||
|
|
||||
|
return schemaFields |
||||
|
} |
||||
|
|
||||
|
// getFieldTypeString converts a protobuf Type to a human-readable string
|
||||
|
func getFieldTypeString(fieldType *schema_pb.Type) string { |
||||
|
if fieldType == nil { |
||||
|
return "unknown" |
||||
|
} |
||||
|
|
||||
|
switch kind := fieldType.Kind.(type) { |
||||
|
case *schema_pb.Type_ScalarType: |
||||
|
return getScalarTypeString(kind.ScalarType) |
||||
|
case *schema_pb.Type_RecordType: |
||||
|
return "record" |
||||
|
case *schema_pb.Type_ListType: |
||||
|
elementType := getFieldTypeString(kind.ListType.ElementType) |
||||
|
return fmt.Sprintf("list<%s>", elementType) |
||||
|
default: |
||||
|
return "unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// getScalarTypeString converts a protobuf ScalarType to a string
|
||||
|
func getScalarTypeString(scalarType schema_pb.ScalarType) string { |
||||
|
switch scalarType { |
||||
|
case schema_pb.ScalarType_BOOL: |
||||
|
return "bool" |
||||
|
case schema_pb.ScalarType_INT32: |
||||
|
return "int32" |
||||
|
case schema_pb.ScalarType_INT64: |
||||
|
return "int64" |
||||
|
case schema_pb.ScalarType_FLOAT: |
||||
|
return "float" |
||||
|
case schema_pb.ScalarType_DOUBLE: |
||||
|
return "double" |
||||
|
case schema_pb.ScalarType_BYTES: |
||||
|
return "bytes" |
||||
|
case schema_pb.ScalarType_STRING: |
||||
|
return "string" |
||||
|
default: |
||||
|
return "unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// convertTopicPublishers converts protobuf TopicPublisher slice to PublisherInfo slice
|
||||
|
func convertTopicPublishers(publishers []*mq_pb.TopicPublisher) []PublisherInfo { |
||||
|
publisherInfos := make([]PublisherInfo, 0, len(publishers)) |
||||
|
|
||||
|
for _, publisher := range publishers { |
||||
|
publisherInfo := PublisherInfo{ |
||||
|
PublisherName: publisher.PublisherName, |
||||
|
ClientID: publisher.ClientId, |
||||
|
PartitionID: publisher.Partition.RangeStart, |
||||
|
Broker: publisher.Broker, |
||||
|
IsActive: publisher.IsActive, |
||||
|
LastPublishedOffset: publisher.LastPublishedOffset, |
||||
|
LastAckedOffset: publisher.LastAckedOffset, |
||||
|
} |
||||
|
|
||||
|
// Convert timestamps
|
||||
|
if publisher.ConnectTimeNs > 0 { |
||||
|
publisherInfo.ConnectTime = time.Unix(0, publisher.ConnectTimeNs) |
||||
|
} |
||||
|
if publisher.LastSeenTimeNs > 0 { |
||||
|
publisherInfo.LastSeenTime = time.Unix(0, publisher.LastSeenTimeNs) |
||||
|
} |
||||
|
|
||||
|
publisherInfos = append(publisherInfos, publisherInfo) |
||||
|
} |
||||
|
|
||||
|
return publisherInfos |
||||
|
} |
||||
|
|
||||
|
// convertTopicSubscribers converts protobuf TopicSubscriber slice to TopicSubscriberInfo slice
|
||||
|
func convertTopicSubscribers(subscribers []*mq_pb.TopicSubscriber) []TopicSubscriberInfo { |
||||
|
subscriberInfos := make([]TopicSubscriberInfo, 0, len(subscribers)) |
||||
|
|
||||
|
for _, subscriber := range subscribers { |
||||
|
subscriberInfo := TopicSubscriberInfo{ |
||||
|
ConsumerGroup: subscriber.ConsumerGroup, |
||||
|
ConsumerID: subscriber.ConsumerId, |
||||
|
ClientID: subscriber.ClientId, |
||||
|
PartitionID: subscriber.Partition.RangeStart, |
||||
|
Broker: subscriber.Broker, |
||||
|
IsActive: subscriber.IsActive, |
||||
|
CurrentOffset: subscriber.CurrentOffset, |
||||
|
LastReceivedOffset: subscriber.LastReceivedOffset, |
||||
|
} |
||||
|
|
||||
|
// Convert timestamps
|
||||
|
if subscriber.ConnectTimeNs > 0 { |
||||
|
subscriberInfo.ConnectTime = time.Unix(0, subscriber.ConnectTimeNs) |
||||
|
} |
||||
|
if subscriber.LastSeenTimeNs > 0 { |
||||
|
subscriberInfo.LastSeenTime = time.Unix(0, subscriber.LastSeenTimeNs) |
||||
|
} |
||||
|
|
||||
|
subscriberInfos = append(subscriberInfos, subscriberInfo) |
||||
|
} |
||||
|
|
||||
|
return subscriberInfos |
||||
|
} |
||||
|
|
||||
|
// findBrokerLeader finds the current broker leader
|
||||
|
func (s *AdminServer) findBrokerLeader() (string, error) { |
||||
|
// First, try to find any broker from the cluster
|
||||
|
var brokers []string |
||||
|
err := s.WithMasterClient(func(client master_pb.SeaweedClient) error { |
||||
|
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{ |
||||
|
ClientType: cluster.BrokerType, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
for _, node := range resp.ClusterNodes { |
||||
|
brokers = append(brokers, node.Address) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return "", fmt.Errorf("failed to list brokers: %v", err) |
||||
|
} |
||||
|
|
||||
|
if len(brokers) == 0 { |
||||
|
return "", fmt.Errorf("no brokers found in cluster") |
||||
|
} |
||||
|
|
||||
|
// Try each broker to find the leader
|
||||
|
for _, brokerAddr := range brokers { |
||||
|
err := s.withBrokerClient(brokerAddr, func(client mq_pb.SeaweedMessagingClient) error { |
||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
||||
|
defer cancel() |
||||
|
|
||||
|
// Try to find broker leader
|
||||
|
_, err := client.FindBrokerLeader(ctx, &mq_pb.FindBrokerLeaderRequest{ |
||||
|
FilerGroup: "", |
||||
|
}) |
||||
|
if err == nil { |
||||
|
return nil // This broker is the leader
|
||||
|
} |
||||
|
return err |
||||
|
}) |
||||
|
if err == nil { |
||||
|
return brokerAddr, nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return "", fmt.Errorf("no broker leader found") |
||||
|
} |
||||
|
|
||||
|
// withBrokerClient connects to a message queue broker and executes a function
|
||||
|
func (s *AdminServer) withBrokerClient(brokerAddress string, fn func(client mq_pb.SeaweedMessagingClient) error) error { |
||||
|
return pb.WithBrokerGrpcClient(false, brokerAddress, s.grpcDialOption, fn) |
||||
|
} |
||||
|
|
||||
|
// convertTopicRetention converts protobuf retention to TopicRetentionInfo
|
||||
|
func convertTopicRetention(retention *mq_pb.TopicRetention) TopicRetentionInfo { |
||||
|
if retention == nil || !retention.Enabled { |
||||
|
return TopicRetentionInfo{ |
||||
|
Enabled: false, |
||||
|
RetentionSeconds: 0, |
||||
|
DisplayValue: 0, |
||||
|
DisplayUnit: "days", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Convert seconds to human-readable format
|
||||
|
seconds := retention.RetentionSeconds |
||||
|
var displayValue int32 |
||||
|
var displayUnit string |
||||
|
|
||||
|
if seconds >= 86400 { // >= 1 day
|
||||
|
displayValue = int32(seconds / 86400) |
||||
|
displayUnit = "days" |
||||
|
} else if seconds >= 3600 { // >= 1 hour
|
||||
|
displayValue = int32(seconds / 3600) |
||||
|
displayUnit = "hours" |
||||
|
} else { |
||||
|
displayValue = int32(seconds) |
||||
|
displayUnit = "seconds" |
||||
|
} |
||||
|
|
||||
|
return TopicRetentionInfo{ |
||||
|
Enabled: retention.Enabled, |
||||
|
RetentionSeconds: seconds, |
||||
|
DisplayValue: displayValue, |
||||
|
DisplayUnit: displayUnit, |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,296 @@ |
|||||
|
package dash |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"path/filepath" |
||||
|
"sort" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/seaweedfs/seaweedfs/weed/glog" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/mq/topic" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" |
||||
|
) |
||||
|
|
||||
|
// TopicRetentionPurger handles topic data purging based on retention policies
|
||||
|
type TopicRetentionPurger struct { |
||||
|
adminServer *AdminServer |
||||
|
} |
||||
|
|
||||
|
// NewTopicRetentionPurger creates a new topic retention purger
|
||||
|
func NewTopicRetentionPurger(adminServer *AdminServer) *TopicRetentionPurger { |
||||
|
return &TopicRetentionPurger{ |
||||
|
adminServer: adminServer, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// PurgeExpiredTopicData purges expired topic data based on retention policies
|
||||
|
func (p *TopicRetentionPurger) PurgeExpiredTopicData() error { |
||||
|
glog.V(1).Infof("Starting topic data purge based on retention policies") |
||||
|
|
||||
|
// Get all topics with retention enabled
|
||||
|
topics, err := p.getTopicsWithRetention() |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to get topics with retention: %v", err) |
||||
|
} |
||||
|
|
||||
|
glog.V(1).Infof("Found %d topics with retention enabled", len(topics)) |
||||
|
|
||||
|
// Process each topic
|
||||
|
for _, topicRetention := range topics { |
||||
|
err := p.purgeTopicData(topicRetention) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to purge data for topic %s: %v", topicRetention.TopicName, err) |
||||
|
continue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
glog.V(1).Infof("Completed topic data purge") |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// TopicRetentionConfig represents a topic with its retention configuration
|
||||
|
type TopicRetentionConfig struct { |
||||
|
TopicName string |
||||
|
Namespace string |
||||
|
Name string |
||||
|
RetentionSeconds int64 |
||||
|
} |
||||
|
|
||||
|
// getTopicsWithRetention retrieves all topics that have retention enabled
|
||||
|
func (p *TopicRetentionPurger) getTopicsWithRetention() ([]TopicRetentionConfig, error) { |
||||
|
var topicsWithRetention []TopicRetentionConfig |
||||
|
|
||||
|
// Find broker leader to get topics
|
||||
|
brokerLeader, err := p.adminServer.findBrokerLeader() |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to find broker leader: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Get all topics from the broker
|
||||
|
err = p.adminServer.withBrokerClient(brokerLeader, func(client mq_pb.SeaweedMessagingClient) error { |
||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
||||
|
defer cancel() |
||||
|
|
||||
|
resp, err := client.ListTopics(ctx, &mq_pb.ListTopicsRequest{}) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
// Check each topic for retention configuration
|
||||
|
for _, pbTopic := range resp.Topics { |
||||
|
configResp, err := client.GetTopicConfiguration(ctx, &mq_pb.GetTopicConfigurationRequest{ |
||||
|
Topic: pbTopic, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
glog.Warningf("Failed to get configuration for topic %s.%s: %v", pbTopic.Namespace, pbTopic.Name, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
// Check if retention is enabled
|
||||
|
if configResp.Retention != nil && configResp.Retention.Enabled && configResp.Retention.RetentionSeconds > 0 { |
||||
|
topicRetention := TopicRetentionConfig{ |
||||
|
TopicName: fmt.Sprintf("%s.%s", pbTopic.Namespace, pbTopic.Name), |
||||
|
Namespace: pbTopic.Namespace, |
||||
|
Name: pbTopic.Name, |
||||
|
RetentionSeconds: configResp.Retention.RetentionSeconds, |
||||
|
} |
||||
|
topicsWithRetention = append(topicsWithRetention, topicRetention) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return topicsWithRetention, nil |
||||
|
} |
||||
|
|
||||
|
// purgeTopicData purges expired data for a specific topic
|
||||
|
func (p *TopicRetentionPurger) purgeTopicData(topicRetention TopicRetentionConfig) error { |
||||
|
glog.V(1).Infof("Purging expired data for topic %s with retention %d seconds", topicRetention.TopicName, topicRetention.RetentionSeconds) |
||||
|
|
||||
|
// Calculate cutoff time
|
||||
|
cutoffTime := time.Now().Add(-time.Duration(topicRetention.RetentionSeconds) * time.Second) |
||||
|
|
||||
|
// Get topic directory
|
||||
|
topicObj := topic.NewTopic(topicRetention.Namespace, topicRetention.Name) |
||||
|
topicDir := topicObj.Dir() |
||||
|
|
||||
|
var purgedDirs []string |
||||
|
|
||||
|
err := p.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { |
||||
|
// List all version directories under the topic directory
|
||||
|
versionStream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ |
||||
|
Directory: topicDir, |
||||
|
Prefix: "", |
||||
|
StartFromFileName: "", |
||||
|
InclusiveStartFrom: false, |
||||
|
Limit: 1000, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to list topic directory %s: %v", topicDir, err) |
||||
|
} |
||||
|
|
||||
|
var versionDirs []VersionDirInfo |
||||
|
|
||||
|
// Collect all version directories
|
||||
|
for { |
||||
|
versionResp, err := versionStream.Recv() |
||||
|
if err != nil { |
||||
|
if err == io.EOF { |
||||
|
break |
||||
|
} |
||||
|
return fmt.Errorf("failed to receive version entries: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Only process directories that are versions (start with "v")
|
||||
|
if versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") { |
||||
|
versionTime, err := p.parseVersionTime(versionResp.Entry.Name) |
||||
|
if err != nil { |
||||
|
glog.Warningf("Failed to parse version time from %s: %v", versionResp.Entry.Name, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
versionDirs = append(versionDirs, VersionDirInfo{ |
||||
|
Name: versionResp.Entry.Name, |
||||
|
VersionTime: versionTime, |
||||
|
ModTime: time.Unix(versionResp.Entry.Attributes.Mtime, 0), |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Sort version directories by time (oldest first)
|
||||
|
sort.Slice(versionDirs, func(i, j int) bool { |
||||
|
return versionDirs[i].VersionTime.Before(versionDirs[j].VersionTime) |
||||
|
}) |
||||
|
|
||||
|
// Keep at least the most recent version directory, even if it's expired
|
||||
|
if len(versionDirs) <= 1 { |
||||
|
glog.V(1).Infof("Topic %s has %d version directories, keeping all", topicRetention.TopicName, len(versionDirs)) |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// Purge expired directories (keep the most recent one)
|
||||
|
for i := 0; i < len(versionDirs)-1; i++ { |
||||
|
versionDir := versionDirs[i] |
||||
|
|
||||
|
// Check if this version directory is expired
|
||||
|
if versionDir.VersionTime.Before(cutoffTime) { |
||||
|
dirPath := filepath.Join(topicDir, versionDir.Name) |
||||
|
|
||||
|
// Delete the entire version directory
|
||||
|
err := p.deleteDirectoryRecursively(client, dirPath) |
||||
|
if err != nil { |
||||
|
glog.Errorf("Failed to delete expired directory %s: %v", dirPath, err) |
||||
|
} else { |
||||
|
purgedDirs = append(purgedDirs, dirPath) |
||||
|
glog.V(1).Infof("Purged expired directory: %s (created: %s)", dirPath, versionDir.VersionTime.Format("2006-01-02 15:04:05")) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
}) |
||||
|
|
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
if len(purgedDirs) > 0 { |
||||
|
glog.V(0).Infof("Purged %d expired directories for topic %s", len(purgedDirs), topicRetention.TopicName) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// VersionDirInfo represents a version directory with its timestamp
|
||||
|
type VersionDirInfo struct { |
||||
|
Name string |
||||
|
VersionTime time.Time |
||||
|
ModTime time.Time |
||||
|
} |
||||
|
|
||||
|
// parseVersionTime parses the version directory name to extract the timestamp
|
||||
|
// Version format: v2025-01-10-05-44-34
|
||||
|
func (p *TopicRetentionPurger) parseVersionTime(versionName string) (time.Time, error) { |
||||
|
// Remove the 'v' prefix
|
||||
|
if !strings.HasPrefix(versionName, "v") { |
||||
|
return time.Time{}, fmt.Errorf("invalid version format: %s", versionName) |
||||
|
} |
||||
|
|
||||
|
timeStr := versionName[1:] // Remove 'v'
|
||||
|
|
||||
|
// Parse the time format: 2025-01-10-05-44-34
|
||||
|
versionTime, err := time.Parse("2006-01-02-15-04-05", timeStr) |
||||
|
if err != nil { |
||||
|
return time.Time{}, fmt.Errorf("failed to parse version time %s: %v", timeStr, err) |
||||
|
} |
||||
|
|
||||
|
return versionTime, nil |
||||
|
} |
||||
|
|
||||
|
// deleteDirectoryRecursively deletes a directory and all its contents
|
||||
|
func (p *TopicRetentionPurger) deleteDirectoryRecursively(client filer_pb.SeaweedFilerClient, dirPath string) error { |
||||
|
// List all entries in the directory
|
||||
|
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ |
||||
|
Directory: dirPath, |
||||
|
Prefix: "", |
||||
|
StartFromFileName: "", |
||||
|
InclusiveStartFrom: false, |
||||
|
Limit: 1000, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to list directory %s: %v", dirPath, err) |
||||
|
} |
||||
|
|
||||
|
// Delete all entries
|
||||
|
for { |
||||
|
resp, err := stream.Recv() |
||||
|
if err != nil { |
||||
|
if err == io.EOF { |
||||
|
break |
||||
|
} |
||||
|
return fmt.Errorf("failed to receive entries: %v", err) |
||||
|
} |
||||
|
|
||||
|
entryPath := filepath.Join(dirPath, resp.Entry.Name) |
||||
|
|
||||
|
if resp.Entry.IsDirectory { |
||||
|
// Recursively delete subdirectory
|
||||
|
err = p.deleteDirectoryRecursively(client, entryPath) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete subdirectory %s: %v", entryPath, err) |
||||
|
} |
||||
|
} else { |
||||
|
// Delete file
|
||||
|
_, err = client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{ |
||||
|
Directory: dirPath, |
||||
|
Name: resp.Entry.Name, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete file %s: %v", entryPath, err) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Delete the directory itself
|
||||
|
parentDir := filepath.Dir(dirPath) |
||||
|
dirName := filepath.Base(dirPath) |
||||
|
|
||||
|
_, err = client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{ |
||||
|
Directory: parentDir, |
||||
|
Name: dirName, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("failed to delete directory %s: %v", dirPath, err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,238 @@ |
|||||
|
package handlers |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/gin-gonic/gin" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/app" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout" |
||||
|
) |
||||
|
|
||||
|
// MessageQueueHandlers contains all the HTTP handlers for message queue management
|
||||
|
type MessageQueueHandlers struct { |
||||
|
adminServer *dash.AdminServer |
||||
|
} |
||||
|
|
||||
|
// NewMessageQueueHandlers creates a new instance of MessageQueueHandlers
|
||||
|
func NewMessageQueueHandlers(adminServer *dash.AdminServer) *MessageQueueHandlers { |
||||
|
return &MessageQueueHandlers{ |
||||
|
adminServer: adminServer, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ShowBrokers renders the message queue brokers page
|
||||
|
func (h *MessageQueueHandlers) ShowBrokers(c *gin.Context) { |
||||
|
// Get cluster brokers data
|
||||
|
brokersData, err := h.adminServer.GetClusterBrokers() |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster brokers: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Set username
|
||||
|
username := c.GetString("username") |
||||
|
if username == "" { |
||||
|
username = "admin" |
||||
|
} |
||||
|
brokersData.Username = username |
||||
|
|
||||
|
// Render HTML template
|
||||
|
c.Header("Content-Type", "text/html") |
||||
|
brokersComponent := app.ClusterBrokers(*brokersData) |
||||
|
layoutComponent := layout.Layout(c, brokersComponent) |
||||
|
err = layoutComponent.Render(c.Request.Context(), c.Writer) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ShowTopics renders the message queue topics page
|
||||
|
func (h *MessageQueueHandlers) ShowTopics(c *gin.Context) { |
||||
|
// Get topics data
|
||||
|
topicsData, err := h.adminServer.GetTopics() |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topics: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Set username
|
||||
|
username := c.GetString("username") |
||||
|
if username == "" { |
||||
|
username = "admin" |
||||
|
} |
||||
|
topicsData.Username = username |
||||
|
|
||||
|
// Render HTML template
|
||||
|
c.Header("Content-Type", "text/html") |
||||
|
topicsComponent := app.Topics(*topicsData) |
||||
|
layoutComponent := layout.Layout(c, topicsComponent) |
||||
|
err = layoutComponent.Render(c.Request.Context(), c.Writer) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ShowSubscribers renders the message queue subscribers page
|
||||
|
func (h *MessageQueueHandlers) ShowSubscribers(c *gin.Context) { |
||||
|
// Get subscribers data
|
||||
|
subscribersData, err := h.adminServer.GetSubscribers() |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscribers: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Set username
|
||||
|
username := c.GetString("username") |
||||
|
if username == "" { |
||||
|
username = "admin" |
||||
|
} |
||||
|
subscribersData.Username = username |
||||
|
|
||||
|
// Render HTML template
|
||||
|
c.Header("Content-Type", "text/html") |
||||
|
subscribersComponent := app.Subscribers(*subscribersData) |
||||
|
layoutComponent := layout.Layout(c, subscribersComponent) |
||||
|
err = layoutComponent.Render(c.Request.Context(), c.Writer) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ShowTopicDetails renders the topic details page
|
||||
|
func (h *MessageQueueHandlers) ShowTopicDetails(c *gin.Context) { |
||||
|
// Get topic parameters from URL
|
||||
|
namespace := c.Param("namespace") |
||||
|
topicName := c.Param("topic") |
||||
|
|
||||
|
if namespace == "" || topicName == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing namespace or topic name"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Get topic details data
|
||||
|
topicDetailsData, err := h.adminServer.GetTopicDetails(namespace, topicName) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topic details: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Set username
|
||||
|
username := c.GetString("username") |
||||
|
if username == "" { |
||||
|
username = "admin" |
||||
|
} |
||||
|
topicDetailsData.Username = username |
||||
|
|
||||
|
// Render HTML template
|
||||
|
c.Header("Content-Type", "text/html") |
||||
|
topicDetailsComponent := app.TopicDetails(*topicDetailsData) |
||||
|
layoutComponent := layout.Layout(c, topicDetailsComponent) |
||||
|
err = layoutComponent.Render(c.Request.Context(), c.Writer) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// GetTopicDetailsAPI returns topic details as JSON for AJAX calls
|
||||
|
func (h *MessageQueueHandlers) GetTopicDetailsAPI(c *gin.Context) { |
||||
|
// Get topic parameters from URL
|
||||
|
namespace := c.Param("namespace") |
||||
|
topicName := c.Param("topic") |
||||
|
|
||||
|
if namespace == "" || topicName == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing namespace or topic name"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Get topic details data
|
||||
|
topicDetailsData, err := h.adminServer.GetTopicDetails(namespace, topicName) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topic details: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Return JSON data
|
||||
|
c.JSON(http.StatusOK, topicDetailsData) |
||||
|
} |
||||
|
|
||||
|
// CreateTopicAPI creates a new topic with retention configuration
|
||||
|
func (h *MessageQueueHandlers) CreateTopicAPI(c *gin.Context) { |
||||
|
var req struct { |
||||
|
Namespace string `json:"namespace" binding:"required"` |
||||
|
Name string `json:"name" binding:"required"` |
||||
|
PartitionCount int32 `json:"partition_count" binding:"required"` |
||||
|
Retention struct { |
||||
|
Enabled bool `json:"enabled"` |
||||
|
RetentionSeconds int64 `json:"retention_seconds"` |
||||
|
} `json:"retention"` |
||||
|
} |
||||
|
|
||||
|
if err := c.ShouldBindJSON(&req); err != nil { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Validate inputs
|
||||
|
if req.PartitionCount < 1 || req.PartitionCount > 100 { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Partition count must be between 1 and 100"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if req.Retention.Enabled && req.Retention.RetentionSeconds <= 0 { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Retention seconds must be positive when retention is enabled"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Create the topic via admin server
|
||||
|
err := h.adminServer.CreateTopicWithRetention(req.Namespace, req.Name, req.PartitionCount, req.Retention.Enabled, req.Retention.RetentionSeconds) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create topic: " + err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusOK, gin.H{ |
||||
|
"message": "Topic created successfully", |
||||
|
"topic": fmt.Sprintf("%s.%s", req.Namespace, req.Name), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
type UpdateTopicRetentionRequest struct { |
||||
|
Namespace string `json:"namespace"` |
||||
|
Name string `json:"name"` |
||||
|
Retention struct { |
||||
|
Enabled bool `json:"enabled"` |
||||
|
RetentionSeconds int64 `json:"retention_seconds"` |
||||
|
} `json:"retention"` |
||||
|
} |
||||
|
|
||||
|
func (h *MessageQueueHandlers) UpdateTopicRetentionAPI(c *gin.Context) { |
||||
|
var request UpdateTopicRetentionRequest |
||||
|
if err := c.ShouldBindJSON(&request); err != nil { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Validate required fields
|
||||
|
if request.Namespace == "" || request.Name == "" { |
||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "namespace and name are required"}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Update the topic retention
|
||||
|
err := h.adminServer.UpdateTopicRetention(request.Namespace, request.Name, request.Retention.Enabled, request.Retention.RetentionSeconds) |
||||
|
if err != nil { |
||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
c.JSON(http.StatusOK, gin.H{ |
||||
|
"message": "Topic retention updated successfully", |
||||
|
"topic": request.Namespace + "." + request.Name, |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
package admin |
||||
|
|
||||
|
import ( |
||||
|
"embed" |
||||
|
"io/fs" |
||||
|
) |
||||
|
|
||||
|
//go:embed static/*
|
||||
|
var StaticFS embed.FS |
||||
|
|
||||
|
// GetStaticFS returns the embedded static filesystem
|
||||
|
func GetStaticFS() (fs.FS, error) { |
||||
|
return fs.Sub(StaticFS, "static") |
||||
|
} |
||||
@ -0,0 +1,144 @@ |
|||||
|
package app |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
) |
||||
|
|
||||
|
templ ClusterBrokers(data dash.ClusterBrokersData) { |
||||
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
||||
|
<h1 class="h2"> |
||||
|
<i class="fas fa-comments me-2"></i>Message Brokers |
||||
|
</h1> |
||||
|
<div class="btn-toolbar mb-2 mb-md-0"> |
||||
|
<div class="btn-group me-2"> |
||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="exportBrokers()"> |
||||
|
<i class="fas fa-download me-1"></i>Export |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div id="brokers-content"> |
||||
|
<!-- Summary Cards --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-xl-12 col-md-12 mb-4"> |
||||
|
<div class="card border-left-primary shadow h-100 py-2"> |
||||
|
<div class="card-body"> |
||||
|
<div class="row no-gutters align-items-center"> |
||||
|
<div class="col mr-2"> |
||||
|
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> |
||||
|
Total Message Brokers |
||||
|
</div> |
||||
|
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
||||
|
{ fmt.Sprintf("%d", data.TotalBrokers) } |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<i class="fas fa-comments fa-2x text-gray-300"></i> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Brokers Table --> |
||||
|
<div class="card shadow mb-4"> |
||||
|
<div class="card-header py-3"> |
||||
|
<h6 class="m-0 font-weight-bold text-primary"> |
||||
|
<i class="fas fa-comments me-2"></i>Message Brokers |
||||
|
</h6> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Brokers) > 0 { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-hover" id="brokersTable"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Address</th> |
||||
|
<th>Version</th> |
||||
|
<th>Data Center</th> |
||||
|
<th>Rack</th> |
||||
|
<th>Created At</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, broker := range data.Brokers { |
||||
|
<tr> |
||||
|
<td> |
||||
|
{ broker.Address } |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-light text-dark">{ broker.Version }</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-light text-dark">{ broker.DataCenter }</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-light text-dark">{ broker.Rack }</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
if !broker.CreatedAt.IsZero() { |
||||
|
{ broker.CreatedAt.Format("2006-01-02 15:04:05") } |
||||
|
} else { |
||||
|
<span class="text-muted">N/A</span> |
||||
|
} |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="text-center py-5"> |
||||
|
<i class="fas fa-comments fa-3x text-muted mb-3"></i> |
||||
|
<h5 class="text-muted">No Message Brokers Found</h5> |
||||
|
<p class="text-muted">No message broker servers are currently available in the cluster.</p> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Last Updated --> |
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<small class="text-muted"> |
||||
|
<i class="fas fa-clock me-1"></i> |
||||
|
Last updated: { data.LastUpdated.Format("2006-01-02 15:04:05") } |
||||
|
</small> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
function exportBrokers() { |
||||
|
const table = document.getElementById('brokersTable'); |
||||
|
if (!table) return; |
||||
|
|
||||
|
let csv = 'Address,Version,Data Center,Rack,Created At\n'; |
||||
|
|
||||
|
const rows = table.querySelectorAll('tbody tr'); |
||||
|
rows.forEach(row => { |
||||
|
const cells = row.querySelectorAll('td'); |
||||
|
if (cells.length >= 5) { |
||||
|
const address = cells[0].textContent.trim(); |
||||
|
const version = cells[1].textContent.trim(); |
||||
|
const dataCenter = cells[2].textContent.trim(); |
||||
|
const rack = cells[3].textContent.trim(); |
||||
|
const createdAt = cells[4].textContent.trim(); |
||||
|
|
||||
|
csv += `"${address}","${version}","${dataCenter}","${rack}","${createdAt}"\n`; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const blob = new Blob([csv], { type: 'text/csv' }); |
||||
|
const url = window.URL.createObjectURL(blob); |
||||
|
const a = document.createElement('a'); |
||||
|
a.href = url; |
||||
|
a.download = 'message-brokers.csv'; |
||||
|
a.click(); |
||||
|
window.URL.revokeObjectURL(url); |
||||
|
} |
||||
|
</script> |
||||
|
} |
||||
@ -0,0 +1,168 @@ |
|||||
|
// Code generated by templ - DO NOT EDIT.
|
||||
|
|
||||
|
// templ: version: v0.3.833
|
||||
|
package app |
||||
|
|
||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
|
||||
|
import "github.com/a-h/templ" |
||||
|
import templruntime "github.com/a-h/templ/runtime" |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
) |
||||
|
|
||||
|
func ClusterBrokers(data dash.ClusterBrokersData) templ.Component { |
||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { |
||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context |
||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { |
||||
|
return templ_7745c5c3_CtxErr |
||||
|
} |
||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) |
||||
|
if !templ_7745c5c3_IsBuffer { |
||||
|
defer func() { |
||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) |
||||
|
if templ_7745c5c3_Err == nil { |
||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr |
||||
|
} |
||||
|
}() |
||||
|
} |
||||
|
ctx = templ.InitializeContext(ctx) |
||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx) |
||||
|
if templ_7745c5c3_Var1 == nil { |
||||
|
templ_7745c5c3_Var1 = templ.NopComponent |
||||
|
} |
||||
|
ctx = templ.ClearChildren(ctx) |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-comments me-2\"></i>Message Brokers</h1><div class=\"btn-toolbar mb-2 mb-md-0\"><div class=\"btn-group me-2\"><button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"exportBrokers()\"><i class=\"fas fa-download me-1\"></i>Export</button></div></div></div><div id=\"brokers-content\"><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-xl-12 col-md-12 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Total Message Brokers</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var2 string |
||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalBrokers)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 34, Col: 47} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-comments fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Brokers Table --><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-comments me-2\"></i>Message Brokers</h6></div><div class=\"card-body\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
if len(data.Brokers) > 0 { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"table-responsive\"><table class=\"table table-hover\" id=\"brokersTable\"><thead><tr><th>Address</th><th>Version</th><th>Data Center</th><th>Rack</th><th>Created At</th></tr></thead> <tbody>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
for _, broker := range data.Brokers { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<tr><td>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var3 string |
||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(broker.Address) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 70, Col: 27} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</td><td><span class=\"badge bg-light text-dark\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var4 string |
||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(broker.Version) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 73, Col: 66} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></td><td><span class=\"badge bg-light text-dark\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var5 string |
||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(broker.DataCenter) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 76, Col: 69} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></td><td><span class=\"badge bg-light text-dark\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var6 string |
||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(broker.Rack) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 79, Col: 63} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></td><td>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
if !broker.CreatedAt.IsZero() { |
||||
|
var templ_7745c5c3_Var7 string |
||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(broker.CreatedAt.Format("2006-01-02 15:04:05")) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 83, Col: 60} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} else { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"text-muted\">N/A</span>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td></tr>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</tbody></table></div>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} else { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"text-center py-5\"><i class=\"fas fa-comments fa-3x text-muted mb-3\"></i><h5 class=\"text-muted\">No Message Brokers Found</h5><p class=\"text-muted\">No message broker servers are currently available in the cluster.</p></div>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var8 string |
||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_brokers.templ`, Line: 108, Col: 67} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</small></div></div></div><script>\n\tfunction exportBrokers() {\n\t\tconst table = document.getElementById('brokersTable');\n\t\tif (!table) return;\n\t\t\n\t\tlet csv = 'Address,Version,Data Center,Rack,Created At\\n';\n\t\t\n\t\tconst rows = table.querySelectorAll('tbody tr');\n\t\trows.forEach(row => {\n\t\t\tconst cells = row.querySelectorAll('td');\n\t\t\tif (cells.length >= 5) {\n\t\t\t\tconst address = cells[0].textContent.trim();\n\t\t\t\tconst version = cells[1].textContent.trim();\n\t\t\t\tconst dataCenter = cells[2].textContent.trim();\n\t\t\t\tconst rack = cells[3].textContent.trim();\n\t\t\t\tconst createdAt = cells[4].textContent.trim();\n\t\t\t\t\n\t\t\t\tcsv += `\"${address}\",\"${version}\",\"${dataCenter}\",\"${rack}\",\"${createdAt}\"\\n`;\n\t\t\t}\n\t\t});\n\t\t\n\t\tconst blob = new Blob([csv], { type: 'text/csv' });\n\t\tconst url = window.URL.createObjectURL(blob);\n\t\tconst a = document.createElement('a');\n\t\ta.href = url;\n\t\ta.download = 'message-brokers.csv';\n\t\ta.click();\n\t\twindow.URL.revokeObjectURL(url);\n\t}\n\t</script>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
return nil |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
var _ = templruntime.GeneratedTemplate |
||||
130
weed/admin/view/app/s3_buckets_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,151 @@ |
|||||
|
package app |
||||
|
|
||||
|
import "fmt" |
||||
|
import "github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
|
||||
|
templ Subscribers(data dash.SubscribersData) { |
||||
|
<div class="container-fluid"> |
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<div class="d-flex justify-content-between align-items-center mb-4"> |
||||
|
<h1 class="h3 mb-0">Message Queue Subscribers</h1> |
||||
|
<small class="text-muted">Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}</small> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Summary Cards --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-md-4"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Total Subscribers</h5> |
||||
|
<h3 class="text-primary">{fmt.Sprintf("%d", data.TotalSubscribers)}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-4"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Active Subscribers</h5> |
||||
|
<h3 class="text-success">{fmt.Sprintf("%d", data.ActiveSubscribers)}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-4"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Inactive Subscribers</h5> |
||||
|
<h3 class="text-warning">{fmt.Sprintf("%d", data.TotalSubscribers - data.ActiveSubscribers)}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Subscribers Table --> |
||||
|
<div class="card"> |
||||
|
<div class="card-header d-flex justify-content-between align-items-center"> |
||||
|
<h5 class="mb-0">Subscribers</h5> |
||||
|
<div> |
||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="exportSubscribersCSV()"> |
||||
|
<i class="fas fa-download me-1"></i>Export CSV |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Subscribers) == 0 { |
||||
|
<div class="text-center py-4"> |
||||
|
<i class="fas fa-user-friends fa-3x text-muted mb-3"></i> |
||||
|
<h5>No Subscribers Found</h5> |
||||
|
<p class="text-muted">No message queue subscribers are currently active.</p> |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-striped" id="subscribersTable"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Subscriber Name</th> |
||||
|
<th>Topic</th> |
||||
|
<th>Consumer Group</th> |
||||
|
<th>Status</th> |
||||
|
<th>Messages Processed</th> |
||||
|
<th>Last Seen</th> |
||||
|
<th>Created</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, subscriber := range data.Subscribers { |
||||
|
<tr> |
||||
|
<td> |
||||
|
<strong>{subscriber.Name}</strong> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-info">{subscriber.Topic}</span> |
||||
|
</td> |
||||
|
<td>{subscriber.ConsumerGroup}</td> |
||||
|
<td> |
||||
|
if subscriber.Status == "active" { |
||||
|
<span class="badge bg-success">Active</span> |
||||
|
} else if subscriber.Status == "inactive" { |
||||
|
<span class="badge bg-warning">Inactive</span> |
||||
|
} else { |
||||
|
<span class="badge bg-secondary">{subscriber.Status}</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td>{fmt.Sprintf("%d", subscriber.MessageCount)}</td> |
||||
|
<td> |
||||
|
if !subscriber.LastSeen.IsZero() { |
||||
|
<span class="text-muted">{subscriber.LastSeen.Format("2006-01-02 15:04:05")}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">Never</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="text-muted">{subscriber.CreatedAt.Format("2006-01-02 15:04:05")}</span> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
function exportSubscribersCSV() { |
||||
|
const table = document.getElementById('subscribersTable'); |
||||
|
if (!table) return; |
||||
|
|
||||
|
let csv = 'Subscriber Name,Topic,Consumer Group,Status,Messages Processed,Last Seen,Created\n'; |
||||
|
|
||||
|
const rows = table.querySelectorAll('tbody tr'); |
||||
|
rows.forEach(row => { |
||||
|
const cells = row.querySelectorAll('td'); |
||||
|
if (cells.length >= 7) { |
||||
|
const rowData = [ |
||||
|
cells[0].querySelector('strong')?.textContent || '', |
||||
|
cells[1].querySelector('.badge')?.textContent || '', |
||||
|
cells[2].textContent || '', |
||||
|
cells[3].querySelector('.badge')?.textContent || '', |
||||
|
cells[4].textContent || '', |
||||
|
cells[5].querySelector('span')?.textContent || '', |
||||
|
cells[6].querySelector('span')?.textContent || '' |
||||
|
]; |
||||
|
csv += rowData.map(field => `"${field.replace(/"/g, '""')}"`).join(',') + '\n'; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); |
||||
|
const link = document.createElement('a'); |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
link.setAttribute('href', url); |
||||
|
link.setAttribute('download', 'subscribers.csv'); |
||||
|
link.style.visibility = 'hidden'; |
||||
|
document.body.appendChild(link); |
||||
|
link.click(); |
||||
|
document.body.removeChild(link); |
||||
|
} |
||||
|
</script> |
||||
|
} |
||||
@ -0,0 +1,246 @@ |
|||||
|
// Code generated by templ - DO NOT EDIT.
|
||||
|
|
||||
|
// templ: version: v0.3.833
|
||||
|
package app |
||||
|
|
||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
|
||||
|
import "github.com/a-h/templ" |
||||
|
import templruntime "github.com/a-h/templ/runtime" |
||||
|
|
||||
|
import "fmt" |
||||
|
import "github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
|
||||
|
func Subscribers(data dash.SubscribersData) templ.Component { |
||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { |
||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context |
||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { |
||||
|
return templ_7745c5c3_CtxErr |
||||
|
} |
||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) |
||||
|
if !templ_7745c5c3_IsBuffer { |
||||
|
defer func() { |
||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) |
||||
|
if templ_7745c5c3_Err == nil { |
||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr |
||||
|
} |
||||
|
}() |
||||
|
} |
||||
|
ctx = templ.InitializeContext(ctx) |
||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx) |
||||
|
if templ_7745c5c3_Var1 == nil { |
||||
|
templ_7745c5c3_Var1 = templ.NopComponent |
||||
|
} |
||||
|
ctx = templ.ClearChildren(ctx) |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\"><div class=\"row\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center mb-4\"><h1 class=\"h3 mb-0\">Message Queue Subscribers</h1><small class=\"text-muted\">Last updated: ") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var2 string |
||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 12, Col: 107} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</small></div><!-- Summary Cards --><div class=\"row mb-4\"><div class=\"col-md-4\"><div class=\"card text-center\"><div class=\"card-body\"><h5 class=\"card-title\">Total Subscribers</h5><h3 class=\"text-primary\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var3 string |
||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalSubscribers)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 21, Col: 98} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h3></div></div></div><div class=\"col-md-4\"><div class=\"card text-center\"><div class=\"card-body\"><h5 class=\"card-title\">Active Subscribers</h5><h3 class=\"text-success\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var4 string |
||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveSubscribers)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 29, Col: 99} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h3></div></div></div><div class=\"col-md-4\"><div class=\"card text-center\"><div class=\"card-body\"><h5 class=\"card-title\">Inactive Subscribers</h5><h3 class=\"text-warning\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var5 string |
||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalSubscribers-data.ActiveSubscribers)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 37, Col: 123} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h3></div></div></div></div><!-- Subscribers Table --><div class=\"card\"><div class=\"card-header d-flex justify-content-between align-items-center\"><h5 class=\"mb-0\">Subscribers</h5><div><button class=\"btn btn-sm btn-outline-secondary\" onclick=\"exportSubscribersCSV()\"><i class=\"fas fa-download me-1\"></i>Export CSV</button></div></div><div class=\"card-body\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
if len(data.Subscribers) == 0 { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"text-center py-4\"><i class=\"fas fa-user-friends fa-3x text-muted mb-3\"></i><h5>No Subscribers Found</h5><p class=\"text-muted\">No message queue subscribers are currently active.</p></div>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} else { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"table-responsive\"><table class=\"table table-striped\" id=\"subscribersTable\"><thead><tr><th>Subscriber Name</th><th>Topic</th><th>Consumer Group</th><th>Status</th><th>Messages Processed</th><th>Last Seen</th><th>Created</th></tr></thead> <tbody>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
for _, subscriber := range data.Subscribers { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr><td><strong>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var6 string |
||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(subscriber.Name) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 78, Col: 76} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</strong></td><td><span class=\"badge bg-info\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var7 string |
||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(subscriber.Topic) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 81, Col: 97} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></td><td>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var8 string |
||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subscriber.ConsumerGroup) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 83, Col: 77} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
if subscriber.Status == "active" { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"badge bg-success\">Active</span>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} else if subscriber.Status == "inactive" { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"badge bg-warning\">Inactive</span>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} else { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"badge bg-secondary\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var9 string |
||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(subscriber.Status) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 90, Col: 107} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var10 string |
||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", subscriber.MessageCount)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 93, Col: 95} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
if !subscriber.LastSeen.IsZero() { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-muted\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var11 string |
||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(subscriber.LastSeen.Format("2006-01-02 15:04:05")) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 96, Col: 131} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} else { |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"text-muted\">Never</span>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td><span class=\"text-muted\">") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
var templ_7745c5c3_Var12 string |
||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(subscriber.CreatedAt.Format("2006-01-02 15:04:05")) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/subscribers.templ`, Line: 102, Col: 128} |
||||
|
} |
||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span></td></tr>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</tbody></table></div>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
} |
||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div></div></div></div><script>\n function exportSubscribersCSV() {\n const table = document.getElementById('subscribersTable');\n if (!table) return;\n \n let csv = 'Subscriber Name,Topic,Consumer Group,Status,Messages Processed,Last Seen,Created\\n';\n \n const rows = table.querySelectorAll('tbody tr');\n rows.forEach(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length >= 7) {\n const rowData = [\n cells[0].querySelector('strong')?.textContent || '',\n cells[1].querySelector('.badge')?.textContent || '',\n cells[2].textContent || '',\n cells[3].querySelector('.badge')?.textContent || '',\n cells[4].textContent || '',\n cells[5].querySelector('span')?.textContent || '',\n cells[6].querySelector('span')?.textContent || ''\n ];\n csv += rowData.map(field => `\"${field.replace(/\"/g, '\"\"')}\"`).join(',') + '\\n';\n }\n });\n \n const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });\n const link = document.createElement('a');\n const url = URL.createObjectURL(blob);\n link.setAttribute('href', url);\n link.setAttribute('download', 'subscribers.csv');\n link.style.visibility = 'hidden';\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>") |
||||
|
if templ_7745c5c3_Err != nil { |
||||
|
return templ_7745c5c3_Err |
||||
|
} |
||||
|
return nil |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
var _ = templruntime.GeneratedTemplate |
||||
@ -0,0 +1,677 @@ |
|||||
|
package app |
||||
|
|
||||
|
import "fmt" |
||||
|
import "github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
import "github.com/seaweedfs/seaweedfs/weed/util" |
||||
|
|
||||
|
templ TopicDetails(data dash.TopicDetailsData) { |
||||
|
<div class="container-fluid"> |
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<!-- Header --> |
||||
|
<div class="d-flex justify-content-between align-items-center mb-4"> |
||||
|
<div> |
||||
|
<nav aria-label="breadcrumb"> |
||||
|
<ol class="breadcrumb"> |
||||
|
<li class="breadcrumb-item"><a href="/mq/topics">Topics</a></li> |
||||
|
<li class="breadcrumb-item active" aria-current="page">{data.TopicName}</li> |
||||
|
</ol> |
||||
|
</nav> |
||||
|
<h1 class="h3 mb-0">Topic Details: {data.TopicName}</h1> |
||||
|
</div> |
||||
|
<small class="text-muted">Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}</small> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Summary Cards --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-md-2"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Partitions</h5> |
||||
|
<h3 class="text-primary">{fmt.Sprintf("%d", len(data.Partitions))}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-2"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Schema Fields</h5> |
||||
|
<h3 class="text-info">{fmt.Sprintf("%d", len(data.Schema))}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-2"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Total Messages</h5> |
||||
|
<h3 class="text-success">{fmt.Sprintf("%d", data.MessageCount)}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-2"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Total Size</h5> |
||||
|
<h3 class="text-warning">{util.BytesToHumanReadable(uint64(data.TotalSize))}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-2"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Publishers</h5> |
||||
|
<h3 class="text-success">{fmt.Sprintf("%d", len(data.Publishers))}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-2"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Subscribers</h5> |
||||
|
<h3 class="text-info">{fmt.Sprintf("%d", len(data.Subscribers))}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Consumer Group Offsets Summary --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-md-12"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Consumer Group Offsets</h5> |
||||
|
<h3 class="text-warning">{fmt.Sprintf("%d", len(data.ConsumerGroupOffsets))}</h3> |
||||
|
<p class="text-muted">Saved consumer progress checkpoints</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Topic Information --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h5 class="mb-0">Topic Information</h5> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<dl class="row"> |
||||
|
<dt class="col-sm-4">Namespace:</dt> |
||||
|
<dd class="col-sm-8">{data.Namespace}</dd> |
||||
|
<dt class="col-sm-4">Name:</dt> |
||||
|
<dd class="col-sm-8">{data.Name}</dd> |
||||
|
<dt class="col-sm-4">Full Name:</dt> |
||||
|
<dd class="col-sm-8">{data.TopicName}</dd> |
||||
|
<dt class="col-sm-4">Created:</dt> |
||||
|
<dd class="col-sm-8">{data.CreatedAt.Format("2006-01-02 15:04:05")}</dd> |
||||
|
</dl> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header d-flex justify-content-between align-items-center"> |
||||
|
<h5 class="mb-0"> |
||||
|
<i class="fas fa-clock me-2"></i>Retention Policy |
||||
|
</h5> |
||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showEditRetentionModal()"> |
||||
|
<i class="fas fa-edit me-1"></i>Edit |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<dl class="row"> |
||||
|
<dt class="col-sm-4">Status:</dt> |
||||
|
<dd class="col-sm-8"> |
||||
|
if data.Retention.Enabled { |
||||
|
<span class="badge bg-success">Enabled</span> |
||||
|
} else { |
||||
|
<span class="badge bg-secondary">Disabled</span> |
||||
|
} |
||||
|
</dd> |
||||
|
<dt class="col-sm-4">Duration:</dt> |
||||
|
<dd class="col-sm-8"> |
||||
|
if data.Retention.Enabled { |
||||
|
<span class="text-success"> |
||||
|
{fmt.Sprintf("%d", data.Retention.DisplayValue)} {data.Retention.DisplayUnit} |
||||
|
</span> |
||||
|
} else { |
||||
|
<span class="text-muted">No retention configured</span> |
||||
|
} |
||||
|
</dd> |
||||
|
</dl> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Schema Information --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-12"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h5 class="mb-0">Schema Definition</h5> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Schema) == 0 { |
||||
|
<p class="text-muted">No schema information available</p> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Field</th> |
||||
|
<th>Type</th> |
||||
|
<th>Required</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, field := range data.Schema { |
||||
|
<tr> |
||||
|
<td><code>{field.Name}</code></td> |
||||
|
<td><span class="badge bg-secondary">{field.Type}</span></td> |
||||
|
<td> |
||||
|
if field.Required { |
||||
|
<i class="fas fa-check text-success"></i> |
||||
|
} else { |
||||
|
<i class="fas fa-times text-muted"></i> |
||||
|
} |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Partitions Table --> |
||||
|
<div class="card"> |
||||
|
<div class="card-header d-flex justify-content-between align-items-center"> |
||||
|
<h5 class="mb-0">Partitions</h5> |
||||
|
<div> |
||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="exportPartitionsCSV()"> |
||||
|
<i class="fas fa-download me-1"></i>Export CSV |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Partitions) == 0 { |
||||
|
<div class="text-center py-4"> |
||||
|
<i class="fas fa-server fa-3x text-muted mb-3"></i> |
||||
|
<h5>No Partitions Found</h5> |
||||
|
<p class="text-muted">No partitions are configured for this topic.</p> |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-striped" id="partitionsTable"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Partition ID</th> |
||||
|
<th>Leader Broker</th> |
||||
|
<th>Follower Broker</th> |
||||
|
<th>Messages</th> |
||||
|
<th>Size</th> |
||||
|
<th>Last Data Time</th> |
||||
|
<th>Created</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, partition := range data.Partitions { |
||||
|
<tr> |
||||
|
<td> |
||||
|
<span class="badge bg-primary">{fmt.Sprintf("%d", partition.ID)}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<strong>{partition.LeaderBroker}</strong> |
||||
|
</td> |
||||
|
<td> |
||||
|
if partition.FollowerBroker != "" { |
||||
|
<span class="text-muted">{partition.FollowerBroker}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">None</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td>{fmt.Sprintf("%d", partition.MessageCount)}</td> |
||||
|
<td>{util.BytesToHumanReadable(uint64(partition.TotalSize))}</td> |
||||
|
<td> |
||||
|
if !partition.LastDataTime.IsZero() { |
||||
|
<span class="text-muted">{partition.LastDataTime.Format("2006-01-02 15:04:05")}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">Never</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="text-muted">{partition.CreatedAt.Format("2006-01-02 15:04:05")}</span> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Publishers and Subscribers --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-12"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h5 class="mb-0">Active Publishers <span class="badge bg-success">{fmt.Sprintf("%d", len(data.Publishers))}</span></h5> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Publishers) == 0 { |
||||
|
<div class="alert alert-info mb-0"> |
||||
|
<i class="fas fa-info-circle"></i> No active publishers found for this topic. |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Publisher</th> |
||||
|
<th>Partition</th> |
||||
|
<th>Broker</th> |
||||
|
<th>Status</th> |
||||
|
<th>Published</th> |
||||
|
<th>Acknowledged</th> |
||||
|
<th>Last Seen</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, publisher := range data.Publishers { |
||||
|
<tr> |
||||
|
<td>{publisher.PublisherName}</td> |
||||
|
<td><span class="badge bg-primary">{fmt.Sprintf("%d", publisher.PartitionID)}</span></td> |
||||
|
<td>{publisher.Broker}</td> |
||||
|
<td> |
||||
|
if publisher.IsActive { |
||||
|
<span class="badge bg-success">Active</span> |
||||
|
} else { |
||||
|
<span class="badge bg-secondary">Inactive</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
if publisher.LastPublishedOffset > 0 { |
||||
|
<span class="text-muted">{fmt.Sprintf("%d", publisher.LastPublishedOffset)}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">-</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
if publisher.LastAckedOffset > 0 { |
||||
|
<span class="text-muted">{fmt.Sprintf("%d", publisher.LastAckedOffset)}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">-</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
if !publisher.LastSeenTime.IsZero() { |
||||
|
<span class="text-muted">{publisher.LastSeenTime.Format("15:04:05")}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">-</span> |
||||
|
} |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-12"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h5 class="mb-0">Active Subscribers <span class="badge bg-info">{fmt.Sprintf("%d", len(data.Subscribers))}</span></h5> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Subscribers) == 0 { |
||||
|
<div class="alert alert-info mb-0"> |
||||
|
<i class="fas fa-info-circle"></i> No active subscribers found for this topic. |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Consumer Group</th> |
||||
|
<th>Consumer ID</th> |
||||
|
<th>Partition</th> |
||||
|
<th>Broker</th> |
||||
|
<th>Status</th> |
||||
|
<th>Received</th> |
||||
|
<th>Acknowledged</th> |
||||
|
<th>Last Seen</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, subscriber := range data.Subscribers { |
||||
|
<tr> |
||||
|
<td>{subscriber.ConsumerGroup}</td> |
||||
|
<td>{subscriber.ConsumerID}</td> |
||||
|
<td><span class="badge bg-primary">{fmt.Sprintf("%d", subscriber.PartitionID)}</span></td> |
||||
|
<td>{subscriber.Broker}</td> |
||||
|
<td> |
||||
|
if subscriber.IsActive { |
||||
|
<span class="badge bg-success">Active</span> |
||||
|
} else { |
||||
|
<span class="badge bg-secondary">Inactive</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
if subscriber.LastReceivedOffset > 0 { |
||||
|
<span class="text-muted">{fmt.Sprintf("%d", subscriber.LastReceivedOffset)}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">-</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
if subscriber.CurrentOffset > 0 { |
||||
|
<span class="text-muted">{fmt.Sprintf("%d", subscriber.CurrentOffset)}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">-</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
if !subscriber.LastSeenTime.IsZero() { |
||||
|
<span class="text-muted">{subscriber.LastSeenTime.Format("15:04:05")}</span> |
||||
|
} else { |
||||
|
<span class="text-muted">-</span> |
||||
|
} |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Consumer Group Offsets --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-12"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h5 class="mb-0">Consumer Group Offsets <span class="badge bg-warning">{fmt.Sprintf("%d", len(data.ConsumerGroupOffsets))}</span></h5> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.ConsumerGroupOffsets) == 0 { |
||||
|
<div class="alert alert-info mb-0"> |
||||
|
<i class="fas fa-info-circle"></i> No consumer group offsets found for this topic. |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Consumer Group</th> |
||||
|
<th>Partition</th> |
||||
|
<th>Offset</th> |
||||
|
<th>Last Updated</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, offset := range data.ConsumerGroupOffsets { |
||||
|
<tr> |
||||
|
<td> |
||||
|
<span class="badge bg-secondary">{offset.ConsumerGroup}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-primary">{fmt.Sprintf("%d", offset.PartitionID)}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<strong>{fmt.Sprintf("%d", offset.Offset)}</strong> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="text-muted">{offset.LastUpdated.Format("2006-01-02 15:04:05")}</span> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
function exportPartitionsCSV() { |
||||
|
const table = document.getElementById('partitionsTable'); |
||||
|
if (!table) return; |
||||
|
|
||||
|
let csv = 'Partition ID,Leader Broker,Follower Broker,Messages,Size,Last Data Time,Created\n'; |
||||
|
|
||||
|
const rows = table.querySelectorAll('tbody tr'); |
||||
|
rows.forEach(row => { |
||||
|
const cells = row.querySelectorAll('td'); |
||||
|
if (cells.length >= 7) { |
||||
|
const rowData = [ |
||||
|
cells[0].querySelector('.badge')?.textContent || '', |
||||
|
cells[1].querySelector('strong')?.textContent || '', |
||||
|
cells[2].textContent || '', |
||||
|
cells[3].textContent || '', |
||||
|
cells[4].textContent || '', |
||||
|
cells[5].querySelector('span')?.textContent || '', |
||||
|
cells[6].querySelector('span')?.textContent || '' |
||||
|
]; |
||||
|
csv += rowData.map(field => `"${field.replace(/"/g, '""')}"`).join(',') + '\n'; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); |
||||
|
const link = document.createElement('a'); |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
link.setAttribute('href', url); |
||||
|
link.setAttribute('download', 'topic_partitions.csv'); |
||||
|
link.style.visibility = 'hidden'; |
||||
|
document.body.appendChild(link); |
||||
|
link.click(); |
||||
|
document.body.removeChild(link); |
||||
|
} |
||||
|
|
||||
|
// Edit retention functions |
||||
|
function showEditRetentionModal() { |
||||
|
const modal = new bootstrap.Modal(document.getElementById('editRetentionModal')); |
||||
|
|
||||
|
// Get current retention values from the page |
||||
|
const currentEnabled = document.querySelector('dd .badge.bg-success') !== null; |
||||
|
const currentDurationElement = document.querySelector('dd .text-success'); |
||||
|
|
||||
|
let currentValue = 7; |
||||
|
let currentUnit = 'days'; |
||||
|
|
||||
|
if (currentEnabled && currentDurationElement) { |
||||
|
const durationText = currentDurationElement.textContent.trim(); |
||||
|
const parts = durationText.split(' '); |
||||
|
if (parts.length >= 2) { |
||||
|
currentValue = parseInt(parts[0]) || 7; |
||||
|
currentUnit = parts[1].toLowerCase(); |
||||
|
// Handle plural forms |
||||
|
if (currentUnit.endsWith('s')) { |
||||
|
currentUnit = currentUnit.slice(0, -1); |
||||
|
} |
||||
|
// Map to our dropdown values |
||||
|
if (currentUnit === 'hour') { |
||||
|
currentUnit = 'hours'; |
||||
|
} else if (currentUnit === 'day') { |
||||
|
currentUnit = 'days'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Set current values in the modal |
||||
|
document.getElementById('editEnableRetention').checked = currentEnabled; |
||||
|
document.getElementById('editRetentionValue').value = currentValue; |
||||
|
document.getElementById('editRetentionUnit').value = currentUnit; |
||||
|
|
||||
|
// Show/hide retention fields based on current state |
||||
|
toggleEditRetentionFields(); |
||||
|
|
||||
|
modal.show(); |
||||
|
} |
||||
|
|
||||
|
function toggleEditRetentionFields() { |
||||
|
const enableRetention = document.getElementById('editEnableRetention'); |
||||
|
const retentionFields = document.getElementById('editRetentionFields'); |
||||
|
|
||||
|
if (enableRetention.checked) { |
||||
|
retentionFields.style.display = 'block'; |
||||
|
} else { |
||||
|
retentionFields.style.display = 'none'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function updateRetention() { |
||||
|
const form = document.getElementById('editRetentionForm'); |
||||
|
const formData = new FormData(form); |
||||
|
|
||||
|
// Get topic details from the page |
||||
|
const topicName = document.querySelector('h1').textContent.replace('Topic Details: ', ''); |
||||
|
const parts = topicName.split('.'); |
||||
|
|
||||
|
if (parts.length < 2) { |
||||
|
alert('Invalid topic name format'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const namespace = parts[0]; |
||||
|
const name = parts.slice(1).join('.'); |
||||
|
|
||||
|
// Convert form data to JSON |
||||
|
const data = { |
||||
|
namespace: namespace, |
||||
|
name: name, |
||||
|
retention: { |
||||
|
enabled: formData.get('editEnableRetention') === 'on', |
||||
|
retention_seconds: 0 |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Calculate retention seconds if enabled |
||||
|
if (data.retention.enabled) { |
||||
|
const retentionValue = parseInt(formData.get('editRetentionValue')); |
||||
|
const retentionUnit = formData.get('editRetentionUnit'); |
||||
|
|
||||
|
if (retentionUnit === 'hours') { |
||||
|
data.retention.retention_seconds = retentionValue * 3600; |
||||
|
} else if (retentionUnit === 'days') { |
||||
|
data.retention.retention_seconds = retentionValue * 86400; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Show loading state |
||||
|
const updateButton = document.querySelector('#editRetentionModal .btn-primary'); |
||||
|
updateButton.disabled = true; |
||||
|
updateButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Updating...'; |
||||
|
|
||||
|
// Send API request |
||||
|
fetch('/api/mq/topics/retention/update', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify(data) |
||||
|
}) |
||||
|
.then(response => response.json()) |
||||
|
.then(result => { |
||||
|
if (result.error) { |
||||
|
alert('Failed to update retention: ' + result.error); |
||||
|
} else { |
||||
|
alert('Retention policy updated successfully!'); |
||||
|
// Close modal and refresh page |
||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editRetentionModal')); |
||||
|
modal.hide(); |
||||
|
window.location.reload(); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
alert('Failed to update retention: ' + error.message); |
||||
|
}) |
||||
|
.finally(() => { |
||||
|
// Reset button state |
||||
|
updateButton.disabled = false; |
||||
|
updateButton.innerHTML = '<i class="fas fa-save me-1"></i>Update Retention'; |
||||
|
}); |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<!-- Edit Retention Modal --> |
||||
|
<div class="modal fade" id="editRetentionModal" tabindex="-1" role="dialog"> |
||||
|
<div class="modal-dialog modal-lg" role="document"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title"> |
||||
|
<i class="fas fa-edit me-2"></i>Edit Retention Policy |
||||
|
</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<form id="editRetentionForm"> |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h6 class="mb-0"> |
||||
|
<i class="fas fa-clock me-2"></i>Retention Configuration |
||||
|
</h6> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<div class="form-check mb-3"> |
||||
|
<input class="form-check-input" type="checkbox" id="editEnableRetention" |
||||
|
name="editEnableRetention" onchange="toggleEditRetentionFields()"> |
||||
|
<label class="form-check-label" for="editEnableRetention"> |
||||
|
Enable data retention |
||||
|
</label> |
||||
|
</div> |
||||
|
<div id="editRetentionFields" style="display: none;"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="editRetentionValue" class="form-label">Retention Duration</label> |
||||
|
<input type="number" class="form-control" id="editRetentionValue" |
||||
|
name="editRetentionValue" min="1" value="7"> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="editRetentionUnit" class="form-label">Unit</label> |
||||
|
<select class="form-control" id="editRetentionUnit" name="editRetentionUnit"> |
||||
|
<option value="hours">Hours</option> |
||||
|
<option value="days" selected>Days</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="alert alert-info"> |
||||
|
<i class="fas fa-info-circle me-2"></i> |
||||
|
Data older than this duration will be automatically purged to save storage space. |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
<div class="modal-footer"> |
||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
||||
|
<button type="button" class="btn btn-primary" onclick="updateRetention()"> |
||||
|
<i class="fas fa-save me-1"></i>Update Retention |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
949
weed/admin/view/app/topic_details_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,511 @@ |
|||||
|
package app |
||||
|
|
||||
|
import "fmt" |
||||
|
import "strings" |
||||
|
import "github.com/seaweedfs/seaweedfs/weed/admin/dash" |
||||
|
|
||||
|
templ Topics(data dash.TopicsData) { |
||||
|
<div class="container-fluid"> |
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<div class="d-flex justify-content-between align-items-center mb-4"> |
||||
|
<h1 class="h3 mb-0">Message Queue Topics</h1> |
||||
|
<small class="text-muted">Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}</small> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Summary Cards --> |
||||
|
<div class="row mb-4"> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Total Topics</h5> |
||||
|
<h3 class="text-primary">{fmt.Sprintf("%d", data.TotalTopics)}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="card text-center"> |
||||
|
<div class="card-body"> |
||||
|
<h5 class="card-title">Available Topics</h5> |
||||
|
<h3 class="text-info">{fmt.Sprintf("%d", len(data.Topics))}</h3> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Topics Table --> |
||||
|
<div class="card"> |
||||
|
<div class="card-header d-flex justify-content-between align-items-center"> |
||||
|
<h5 class="mb-0">Topics</h5> |
||||
|
<div> |
||||
|
<button class="btn btn-sm btn-primary me-2" onclick="showCreateTopicModal()"> |
||||
|
<i class="fas fa-plus me-1"></i>Create Topic |
||||
|
</button> |
||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="exportTopicsCSV()"> |
||||
|
<i class="fas fa-download me-1"></i>Export CSV |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
if len(data.Topics) == 0 { |
||||
|
<div class="text-center py-4"> |
||||
|
<i class="fas fa-list-alt fa-3x text-muted mb-3"></i> |
||||
|
<h5>No Topics Found</h5> |
||||
|
<p class="text-muted">No message queue topics are currently configured.</p> |
||||
|
</div> |
||||
|
} else { |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-striped" id="topicsTable"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Namespace</th> |
||||
|
<th>Topic Name</th> |
||||
|
<th>Partitions</th> |
||||
|
<th>Retention</th> |
||||
|
<th>Actions</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
for _, topic := range data.Topics { |
||||
|
<tr class="topic-row" data-topic-name={topic.Name} style="cursor: pointer;"> |
||||
|
<td> |
||||
|
<span class="badge bg-secondary">{func() string { |
||||
|
idx := strings.LastIndex(topic.Name, ".") |
||||
|
if idx == -1 { |
||||
|
return "default" |
||||
|
} |
||||
|
return topic.Name[:idx] |
||||
|
}()}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
<strong>{func() string { |
||||
|
idx := strings.LastIndex(topic.Name, ".") |
||||
|
if idx == -1 { |
||||
|
return topic.Name |
||||
|
} |
||||
|
return topic.Name[idx+1:] |
||||
|
}()}</strong> |
||||
|
</td> |
||||
|
<td> |
||||
|
<span class="badge bg-info">{fmt.Sprintf("%d", topic.Partitions)}</span> |
||||
|
</td> |
||||
|
<td> |
||||
|
if topic.Retention.Enabled { |
||||
|
<span class="badge bg-success"> |
||||
|
<i class="fas fa-clock me-1"></i> |
||||
|
{fmt.Sprintf("%d %s", topic.Retention.DisplayValue, topic.Retention.DisplayUnit)} |
||||
|
</span> |
||||
|
} else { |
||||
|
<span class="badge bg-secondary"> |
||||
|
<i class="fas fa-times me-1"></i>Disabled |
||||
|
</span> |
||||
|
} |
||||
|
</td> |
||||
|
<td> |
||||
|
<button class="btn btn-sm btn-outline-primary" onclick={ templ.ComponentScript{Call: fmt.Sprintf("viewTopicDetails('%s')", topic.Name)} }> |
||||
|
<i class="fas fa-info-circle me-1"></i>Details |
||||
|
</button> |
||||
|
</td> |
||||
|
</tr> |
||||
|
<tr class="topic-details-row" id={ fmt.Sprintf("details-%s", strings.ReplaceAll(topic.Name, ".", "_")) } style="display: none;"> |
||||
|
<td colspan="5"> |
||||
|
<div class="topic-details-content"> |
||||
|
<div class="text-center py-3"> |
||||
|
<i class="fas fa-spinner fa-spin"></i> Loading topic details... |
||||
|
</div> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
function viewTopicDetails(topicName) { |
||||
|
const parts = topicName.split('.'); |
||||
|
if (parts.length >= 2) { |
||||
|
const namespace = parts[0]; |
||||
|
const topic = parts.slice(1).join('.'); |
||||
|
window.location.href = `/mq/topics/${namespace}/${topic}`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function toggleTopicDetails(topicName) { |
||||
|
const safeName = topicName.replace(/\./g, '_'); |
||||
|
const detailsRow = document.getElementById(`details-${safeName}`); |
||||
|
if (!detailsRow) return; |
||||
|
|
||||
|
if (detailsRow.style.display === 'none') { |
||||
|
// Show details row and load data |
||||
|
detailsRow.style.display = 'table-row'; |
||||
|
loadTopicDetails(topicName); |
||||
|
} else { |
||||
|
// Hide details row |
||||
|
detailsRow.style.display = 'none'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function loadTopicDetails(topicName) { |
||||
|
const parts = topicName.split('.'); |
||||
|
if (parts.length < 2) return; |
||||
|
|
||||
|
const namespace = parts[0]; |
||||
|
const topic = parts.slice(1).join('.'); |
||||
|
const safeName = topicName.replace(/\./g, '_'); |
||||
|
const contentDiv = document.querySelector(`#details-${safeName} .topic-details-content`); |
||||
|
|
||||
|
if (!contentDiv) return; |
||||
|
|
||||
|
// Show loading spinner |
||||
|
contentDiv.innerHTML = ` |
||||
|
<div class="text-center py-3"> |
||||
|
<i class="fas fa-spinner fa-spin"></i> Loading topic details... |
||||
|
</div> |
||||
|
`; |
||||
|
|
||||
|
// Make AJAX call to get topic details |
||||
|
fetch(`/api/mq/topics/${namespace}/${topic}`) |
||||
|
.then(response => response.json()) |
||||
|
.then(data => { |
||||
|
if (data.error) { |
||||
|
contentDiv.innerHTML = ` |
||||
|
<div class="alert alert-danger" role="alert"> |
||||
|
<i class="fas fa-exclamation-triangle"></i> Error: ${data.error} |
||||
|
</div> |
||||
|
`; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Render topic details |
||||
|
contentDiv.innerHTML = renderTopicDetails(data); |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
contentDiv.innerHTML = ` |
||||
|
<div class="alert alert-danger" role="alert"> |
||||
|
<i class="fas fa-exclamation-triangle"></i> Failed to load topic details: ${error.message} |
||||
|
</div> |
||||
|
`; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function renderTopicDetails(data) { |
||||
|
const createdAt = new Date(data.created_at).toLocaleString(); |
||||
|
const lastUpdated = new Date(data.last_updated).toLocaleString(); |
||||
|
|
||||
|
let schemaHtml = ''; |
||||
|
if (data.schema && data.schema.length > 0) { |
||||
|
schemaHtml = ` |
||||
|
<div class="col-md-6"> |
||||
|
<h6>Schema Fields</h6> |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Field</th> |
||||
|
<th>Type</th> |
||||
|
<th>Required</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
${data.schema.map(field => ` |
||||
|
<tr> |
||||
|
<td>${field.name}</td> |
||||
|
<td><span class="badge bg-secondary">${field.type}</span></td> |
||||
|
<td>${field.required ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-light text-dark">No</span>'}</td> |
||||
|
</tr> |
||||
|
`).join('')} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
`; |
||||
|
} |
||||
|
|
||||
|
let partitionsHtml = ''; |
||||
|
if (data.partitions && data.partitions.length > 0) { |
||||
|
partitionsHtml = ` |
||||
|
<div class="col-md-6"> |
||||
|
<h6>Partitions</h6> |
||||
|
<div class="table-responsive"> |
||||
|
<table class="table table-sm"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>ID</th> |
||||
|
<th>Leader</th> |
||||
|
<th>Follower</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
${data.partitions.map(partition => ` |
||||
|
<tr> |
||||
|
<td>${partition.id}</td> |
||||
|
<td>${partition.leader_broker || 'N/A'}</td> |
||||
|
<td>${partition.follower_broker || 'N/A'}</td> |
||||
|
</tr> |
||||
|
`).join('')} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
`; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
return ` |
||||
|
<div class="card"> |
||||
|
<div class="card-header"> |
||||
|
<h5>Topic Details: ${data.namespace}.${data.name}</h5> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<div class="row mb-3"> |
||||
|
<div class="col-md-3"> |
||||
|
<strong>Namespace:</strong> ${data.namespace} |
||||
|
</div> |
||||
|
<div class="col-md-3"> |
||||
|
<strong>Topic Name:</strong> ${data.name} |
||||
|
</div> |
||||
|
<div class="col-md-3"> |
||||
|
<strong>Created:</strong> ${createdAt} |
||||
|
</div> |
||||
|
<div class="col-md-3"> |
||||
|
<strong>Last Updated:</strong> ${lastUpdated} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="row mb-3"> |
||||
|
${schemaHtml} |
||||
|
${partitionsHtml} |
||||
|
</div> |
||||
|
|
||||
|
</div> |
||||
|
</div> |
||||
|
`; |
||||
|
} |
||||
|
|
||||
|
function exportTopicsCSV() { |
||||
|
const table = document.getElementById('topicsTable'); |
||||
|
if (!table) return; |
||||
|
|
||||
|
let csv = 'Namespace,Topic Name,Partitions,Retention\n'; |
||||
|
|
||||
|
const rows = table.querySelectorAll('tbody tr.topic-row'); |
||||
|
rows.forEach(row => { |
||||
|
const cells = row.querySelectorAll('td'); |
||||
|
if (cells.length >= 4) { |
||||
|
const rowData = [ |
||||
|
cells[0].querySelector('.badge')?.textContent || '', // Namespace |
||||
|
cells[1].querySelector('strong')?.textContent || '', // Topic Name |
||||
|
cells[2].querySelector('.badge')?.textContent || '', // Partitions |
||||
|
cells[3].querySelector('.badge')?.textContent || '' // Retention |
||||
|
]; |
||||
|
csv += rowData.map(field => `"${field.replace(/"/g, '""')}"`).join(',') + '\n'; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); |
||||
|
const link = document.createElement('a'); |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
link.setAttribute('href', url); |
||||
|
link.setAttribute('download', 'topics.csv'); |
||||
|
link.style.visibility = 'hidden'; |
||||
|
document.body.appendChild(link); |
||||
|
link.click(); |
||||
|
document.body.removeChild(link); |
||||
|
} |
||||
|
|
||||
|
// Topic creation functions |
||||
|
function showCreateTopicModal() { |
||||
|
const modal = new bootstrap.Modal(document.getElementById('createTopicModal')); |
||||
|
modal.show(); |
||||
|
} |
||||
|
|
||||
|
function toggleRetentionFields() { |
||||
|
const enableRetention = document.getElementById('enableRetention'); |
||||
|
const retentionFields = document.getElementById('retentionFields'); |
||||
|
|
||||
|
if (enableRetention.checked) { |
||||
|
retentionFields.style.display = 'block'; |
||||
|
} else { |
||||
|
retentionFields.style.display = 'none'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function createTopic() { |
||||
|
const form = document.getElementById('createTopicForm'); |
||||
|
const formData = new FormData(form); |
||||
|
|
||||
|
// Convert form data to JSON |
||||
|
const data = { |
||||
|
namespace: formData.get('namespace'), |
||||
|
name: formData.get('name'), |
||||
|
partition_count: parseInt(formData.get('partitionCount')), |
||||
|
retention: { |
||||
|
enabled: formData.get('enableRetention') === 'on', |
||||
|
retention_seconds: 0 |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Calculate retention seconds if enabled |
||||
|
if (data.retention.enabled) { |
||||
|
const retentionValue = parseInt(formData.get('retentionValue')); |
||||
|
const retentionUnit = formData.get('retentionUnit'); |
||||
|
|
||||
|
if (retentionUnit === 'hours') { |
||||
|
data.retention.retention_seconds = retentionValue * 3600; |
||||
|
} else if (retentionUnit === 'days') { |
||||
|
data.retention.retention_seconds = retentionValue * 86400; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Validate required fields |
||||
|
if (!data.namespace || !data.name || !data.partition_count) { |
||||
|
alert('Please fill in all required fields'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Show loading state |
||||
|
const createButton = document.querySelector('#createTopicModal .btn-primary'); |
||||
|
createButton.disabled = true; |
||||
|
createButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Creating...'; |
||||
|
|
||||
|
// Send API request |
||||
|
fetch('/api/mq/topics/create', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify(data) |
||||
|
}) |
||||
|
.then(response => response.json()) |
||||
|
.then(result => { |
||||
|
if (result.error) { |
||||
|
alert('Failed to create topic: ' + result.error); |
||||
|
} else { |
||||
|
alert('Topic created successfully!'); |
||||
|
// Close modal and refresh page |
||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createTopicModal')); |
||||
|
modal.hide(); |
||||
|
window.location.reload(); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
alert('Failed to create topic: ' + error.message); |
||||
|
}) |
||||
|
.finally(() => { |
||||
|
// Reset button state |
||||
|
createButton.disabled = false; |
||||
|
createButton.innerHTML = '<i class="fas fa-plus me-1"></i>Create Topic'; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Add click event listeners to topic rows |
||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||
|
document.querySelectorAll('.topic-row').forEach(row => { |
||||
|
row.addEventListener('click', function() { |
||||
|
const topicName = this.getAttribute('data-topic-name'); |
||||
|
toggleTopicDetails(topicName); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<!-- Create Topic Modal --> |
||||
|
<div class="modal fade" id="createTopicModal" tabindex="-1" role="dialog"> |
||||
|
<div class="modal-dialog modal-lg" role="document"> |
||||
|
<div class="modal-content"> |
||||
|
<div class="modal-header"> |
||||
|
<h5 class="modal-title"> |
||||
|
<i class="fas fa-plus me-2"></i>Create New Topic |
||||
|
</h5> |
||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
||||
|
</div> |
||||
|
<div class="modal-body"> |
||||
|
<form id="createTopicForm"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="topicNamespace" class="form-label">Namespace *</label> |
||||
|
<input type="text" class="form-control" id="topicNamespace" name="namespace" required |
||||
|
placeholder="e.g., default"> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="topicName" class="form-label">Topic Name *</label> |
||||
|
<input type="text" class="form-control" id="topicName" name="name" required |
||||
|
placeholder="e.g., user-events"> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="partitionCount" class="form-label">Partition Count *</label> |
||||
|
<input type="number" class="form-control" id="partitionCount" name="partitionCount" |
||||
|
required min="1" max="100" value="6"> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Retention Configuration --> |
||||
|
<div class="card mt-3"> |
||||
|
<div class="card-header"> |
||||
|
<h6 class="mb-0"> |
||||
|
<i class="fas fa-clock me-2"></i>Retention Policy |
||||
|
</h6> |
||||
|
</div> |
||||
|
<div class="card-body"> |
||||
|
<div class="form-check mb-3"> |
||||
|
<input class="form-check-input" type="checkbox" id="enableRetention" |
||||
|
name="enableRetention" onchange="toggleRetentionFields()"> |
||||
|
<label class="form-check-label" for="enableRetention"> |
||||
|
Enable data retention |
||||
|
</label> |
||||
|
</div> |
||||
|
<div id="retentionFields" style="display: none;"> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="retentionValue" class="form-label">Retention Duration</label> |
||||
|
<input type="number" class="form-control" id="retentionValue" |
||||
|
name="retentionValue" min="1" value="7"> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-md-6"> |
||||
|
<div class="mb-3"> |
||||
|
<label for="retentionUnit" class="form-label">Unit</label> |
||||
|
<select class="form-control" id="retentionUnit" name="retentionUnit"> |
||||
|
<option value="hours">Hours</option> |
||||
|
<option value="days" selected>Days</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="alert alert-info"> |
||||
|
<i class="fas fa-info-circle me-2"></i> |
||||
|
Data older than this duration will be automatically purged to save storage space. |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
<div class="modal-footer"> |
||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
||||
|
<button type="button" class="btn btn-primary" onclick="createTopic()"> |
||||
|
<i class="fas fa-plus me-1"></i>Create Topic |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
230
weed/admin/view/app/topics_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
1328
weed/pb/mq_pb/mq_broker.pb.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
740
weed/pb/worker_pb/worker.pb.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue