first commit
This commit is contained in:
commit
beb09cb7c6
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@ -0,0 +1,31 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitea
|
||||
|
||||
# IDE and editor
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build artifacts
|
||||
nats-ui
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Documentation (not needed in image)
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yaml
|
||||
docker-compose*.yml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
86
.gitea/workflows/ci-cd.yaml
Normal file
86
.gitea/workflows/ci-cd.yaml
Normal file
@ -0,0 +1,86 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.21'
|
||||
IMAGE_NAME: 'nats-ui'
|
||||
DOCKER_REGISTRY: hub.p42.ru
|
||||
IMAGE_REPO: hub.p42.ru/redirsvr/nats-ui
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run linter
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.64.8
|
||||
args: --timeout=5m
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race ./...
|
||||
|
||||
build:
|
||||
name: Build & Push Docker image (hub.p42.ru)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to hub.p42.ru
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.HUB_USER }}
|
||||
password: ${{ secrets.HUB_SECRET }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.IMAGE_REPO }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=ref,event=branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# hub.p42.ru ограничивает размер blob-uploads (413), поэтому registry-cache отключаем.
|
||||
# Используем inline cache (без отдельного :buildcache тега).
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_REPO }}:latest
|
||||
cache-to: type=inline
|
||||
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Binaries
|
||||
nats-ui
|
||||
config.yaml
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY main.go ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o nats-ui main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/nats-ui .
|
||||
COPY index.html config.yaml ./
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./nats-ui"]
|
||||
CMD ["config.yaml"]
|
||||
194
README.md
Normal file
194
README.md
Normal file
@ -0,0 +1,194 @@
|
||||
# NATS Queue Visualizer
|
||||
|
||||
Веб-приложение на Go для визуализации содержимого очередей NATS в реальном времени.
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🔄 Подписка на NATS subjects/очереди в реальном времени
|
||||
- 📊 Красивый веб-интерфейс для просмотра сообщений
|
||||
- 🔍 Фильтрация по subject и поиск по содержимому
|
||||
- 📈 Статистика: количество сообщений, subjects, общий размер
|
||||
- 🌐 WebSocket для обновлений в реальном времени
|
||||
- 💾 Хранение последних N сообщений в памяти
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.21 или выше
|
||||
- NATS сервер (локальный или удаленный)
|
||||
|
||||
## Установка
|
||||
|
||||
1. Клонируйте репозиторий или скачайте файлы
|
||||
2. Установите зависимости:
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Все настройки приложения находятся в файле `config.yaml`. Создайте этот файл или отредактируйте существующий:
|
||||
|
||||
```yaml
|
||||
# URL NATS сервера
|
||||
nats_url: "nats://localhost:4222"
|
||||
|
||||
# Аутентификация NATS (используйте один из вариантов)
|
||||
# Вариант 1: Токен
|
||||
# token: "your-token-here"
|
||||
|
||||
# Вариант 2: Имя пользователя и пароль
|
||||
# username: "your-username"
|
||||
# password: "your-password"
|
||||
|
||||
# Список subjects для подписки (через запятую)
|
||||
subjects: ">"
|
||||
|
||||
# Порт HTTP сервера
|
||||
port: "8080"
|
||||
|
||||
# Максимальное количество сообщений для хранения в памяти
|
||||
max_messages: 1000
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Базовое использование
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
Приложение автоматически загрузит конфигурацию из `config.yaml` в текущей директории.
|
||||
|
||||
### Использование другого конфигурационного файла
|
||||
|
||||
```bash
|
||||
go run main.go /path/to/custom-config.yaml
|
||||
```
|
||||
|
||||
Можно указать путь к другому конфигурационному файлу в качестве аргумента командной строки.
|
||||
|
||||
### Примеры subjects
|
||||
|
||||
- `>` - все subjects
|
||||
- `orders.*` - все subjects, начинающиеся с `orders.`
|
||||
- `events.>` - все subjects в пространстве имен `events`
|
||||
- `orders.created,orders.updated` - конкретные subjects через запятую
|
||||
|
||||
## Веб-интерфейс
|
||||
|
||||
Откройте браузер и перейдите на `http://localhost:8080`
|
||||
|
||||
### Функции интерфейса:
|
||||
|
||||
1. **Фильтр по subject** - выберите конкретный subject из выпадающего списка
|
||||
2. **Поиск** - введите текст для поиска по содержимому сообщений
|
||||
3. **Очистить** - удалить все сообщения из памяти (не влияет на NATS)
|
||||
4. **Обновить** - перезагрузить все сообщения с сервера
|
||||
|
||||
### Статистика
|
||||
|
||||
Интерфейс показывает:
|
||||
- Общее количество полученных сообщений
|
||||
- Количество уникальных subjects
|
||||
- Общий размер всех сообщений в байтах
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /` - веб-интерфейс
|
||||
- `GET /api/messages?subject=...` - получить все сообщения (опционально отфильтрованные по subject)
|
||||
- `GET /api/subjects` - получить список всех subjects
|
||||
- `WS /ws` - WebSocket для получения сообщений в реальном времени
|
||||
|
||||
## Примеры конфигурации
|
||||
|
||||
### Аутентификация с токеном
|
||||
|
||||
```yaml
|
||||
nats_url: "nats://nats.example.com:4222"
|
||||
token: "your-secret-token"
|
||||
subjects: ">"
|
||||
port: "8080"
|
||||
max_messages: 1000
|
||||
```
|
||||
|
||||
### Аутентификация с username/password
|
||||
|
||||
```yaml
|
||||
nats_url: "nats://nats.example.com:4222"
|
||||
username: "myuser"
|
||||
password: "mypassword"
|
||||
subjects: ">"
|
||||
port: "8080"
|
||||
max_messages: 1000
|
||||
```
|
||||
|
||||
### Подписка на конкретные subjects
|
||||
|
||||
Отредактируйте `config.yaml`:
|
||||
```yaml
|
||||
nats_url: "nats://localhost:4222"
|
||||
subjects: "orders.created,orders.updated,orders.deleted"
|
||||
port: "8080"
|
||||
max_messages: 1000
|
||||
```
|
||||
|
||||
### Подписка на все subjects в пространстве имен
|
||||
|
||||
```yaml
|
||||
subjects: "events.>"
|
||||
```
|
||||
|
||||
### Подключение к удаленному NATS серверу
|
||||
|
||||
```yaml
|
||||
nats_url: "nats://nats.example.com:4222"
|
||||
```
|
||||
|
||||
### Увеличение лимита сообщений
|
||||
|
||||
```yaml
|
||||
max_messages: 5000
|
||||
```
|
||||
|
||||
## Сборка
|
||||
|
||||
Для создания исполняемого файла:
|
||||
|
||||
```bash
|
||||
go build -o nats-ui main.go
|
||||
```
|
||||
|
||||
Затем запустите:
|
||||
|
||||
```bash
|
||||
./nats-ui
|
||||
```
|
||||
|
||||
Или с указанием конфигурационного файла:
|
||||
|
||||
```bash
|
||||
./nats-ui /path/to/config.yaml
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
nats-ui/
|
||||
├── main.go # Основной код приложения
|
||||
├── index.html # Веб-интерфейс
|
||||
├── config.yaml # Конфигурационный файл
|
||||
├── go.mod # Зависимости Go
|
||||
└── README.md # Документация
|
||||
```
|
||||
|
||||
## Зависимости
|
||||
|
||||
- `github.com/nats-io/nats.go` - клиент NATS
|
||||
- `github.com/gorilla/websocket` - WebSocket поддержка
|
||||
- `gopkg.in/yaml.v3` - парсер YAML конфигурации
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
11
docker-compose.yaml
Normal file
11
docker-compose.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
services:
|
||||
nats-ui:
|
||||
image: hub.p42.ru/redirsvr/nats-ui:latest
|
||||
container_name: nats-ui
|
||||
ports:
|
||||
- 0.0.0.0:8080:8080
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Bangkok
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
@ -0,0 +1,18 @@
|
||||
module nats-ui
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/nats-io/nats.go v1.31.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.6 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
||||
20
go.sum
Normal file
20
go.sum
Normal file
@ -0,0 +1,20 @@
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
|
||||
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E=
|
||||
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
|
||||
github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY=
|
||||
github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
503
index.html
Normal file
503
index.html
Normal file
@ -0,0 +1,503 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NATS Queue Visualizer</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header .status {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 20px 30px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #ddd;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
select, input {
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
select:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px 30px;
|
||||
background: #f9f9f9;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
padding: 20px 30px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
background: #f9f9f9;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-subject {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message-data {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.message-size {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 NATS Queue Visualizer</h1>
|
||||
<div id="status" class="status disconnected">Отключено</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="filter-group">
|
||||
<label>Subject:</label>
|
||||
<select id="subjectFilter">
|
||||
<option value="">Все subjects</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group search-box">
|
||||
<label>Поиск:</label>
|
||||
<input type="text" id="searchInput" placeholder="Поиск по содержимому...">
|
||||
</div>
|
||||
<button class="btn btn-danger" onclick="clearMessages()">Очистить</button>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="totalMessages">0</div>
|
||||
<div class="stat-label">Всего сообщений</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="uniqueSubjects">0</div>
|
||||
<div class="stat-label">Уникальных subjects</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="totalBytes">0</div>
|
||||
<div class="stat-label">Всего байт</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-container" id="messagesContainer">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<p>Ожидание сообщений из NATS...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let allMessages = [];
|
||||
let currentFilter = '';
|
||||
let currentSearch = '';
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
updateStatus(true);
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// Проверяем, массив ли это (все сообщения при подключении) или одно сообщение
|
||||
if (Array.isArray(data)) {
|
||||
// При подключении сервер отправляет все существующие сообщения массивом
|
||||
allMessages = data.reverse(); // Переворачиваем, чтобы новые были сверху
|
||||
updateMessages();
|
||||
updateStats();
|
||||
updateSubjectFilter();
|
||||
console.log(`Loaded ${data.length} existing messages`);
|
||||
} else if (data.id && data.subject) {
|
||||
// Одно новое сообщение в реальном времени
|
||||
addMessage(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateStatus(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
updateStatus(false);
|
||||
console.log('WebSocket disconnected');
|
||||
// Переподключение через 3 секунды
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function updateStatus(connected) {
|
||||
const statusEl = document.getElementById('status');
|
||||
if (connected) {
|
||||
statusEl.textContent = 'Подключено';
|
||||
statusEl.className = 'status connected';
|
||||
} else {
|
||||
statusEl.textContent = 'Отключено';
|
||||
statusEl.className = 'status disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(message) {
|
||||
allMessages.unshift(message); // Добавляем в начало
|
||||
updateMessages();
|
||||
updateStats();
|
||||
updateSubjectFilter(); // Автоматически обновляем список subjects
|
||||
}
|
||||
|
||||
function updateMessages() {
|
||||
const container = document.getElementById('messagesContainer');
|
||||
|
||||
let filtered = allMessages;
|
||||
|
||||
if (currentFilter) {
|
||||
filtered = filtered.filter(m => m.subject === currentFilter);
|
||||
}
|
||||
|
||||
if (currentSearch) {
|
||||
const searchLower = currentSearch.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
m.data.toLowerCase().includes(searchLower) ||
|
||||
m.subject.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>Нет сообщений, соответствующих фильтрам</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(msg => `
|
||||
<div class="message">
|
||||
<div class="message-header">
|
||||
<span class="message-subject">${escapeHtml(msg.subject)}</span>
|
||||
<div class="message-meta">
|
||||
<span>${formatTime(msg.timestamp)}</span>
|
||||
<span class="message-size">${formatBytes(msg.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-data">${escapeHtml(msg.data)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('totalMessages').textContent = allMessages.length;
|
||||
|
||||
const uniqueSubjects = new Set(allMessages.map(m => m.subject));
|
||||
document.getElementById('uniqueSubjects').textContent = uniqueSubjects.size;
|
||||
|
||||
const totalBytes = allMessages.reduce((sum, m) => sum + m.size, 0);
|
||||
document.getElementById('totalBytes').textContent = formatBytes(totalBytes);
|
||||
}
|
||||
|
||||
function updateSubjectFilter() {
|
||||
const subjects = [...new Set(allMessages.map(m => m.subject))];
|
||||
const select = document.getElementById('subjectFilter');
|
||||
const currentValue = select.value;
|
||||
|
||||
select.innerHTML = '<option value="">Все subjects</option>' +
|
||||
subjects.map(s => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
|
||||
|
||||
if (currentValue && subjects.includes(currentValue)) {
|
||||
select.value = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshMessages() {
|
||||
fetch('/api/messages')
|
||||
.then(res => res.json())
|
||||
.then(messages => {
|
||||
allMessages = messages.reverse(); // Переворачиваем, чтобы новые были сверху
|
||||
updateSubjectFilter();
|
||||
updateMessages();
|
||||
updateStats();
|
||||
})
|
||||
.catch(err => console.error('Error fetching messages:', err));
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
if (confirm('Вы уверены, что хотите очистить все сообщения?')) {
|
||||
allMessages = [];
|
||||
updateMessages();
|
||||
updateStats();
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Обработчики событий
|
||||
document.getElementById('subjectFilter').addEventListener('change', (e) => {
|
||||
currentFilter = e.target.value;
|
||||
updateMessages();
|
||||
});
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||
currentSearch = e.target.value;
|
||||
updateMessages();
|
||||
});
|
||||
|
||||
// Инициализация - только WebSocket, все сообщения придут автоматически
|
||||
connectWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
297
main.go
Normal file
297
main.go
Normal file
@ -0,0 +1,297 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nats-io/nats.go"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Data string `json:"data"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type MessageStore struct {
|
||||
mu sync.RWMutex
|
||||
messages []Message
|
||||
maxSize int
|
||||
}
|
||||
|
||||
func NewMessageStore(maxSize int) *MessageStore {
|
||||
return &MessageStore{messages: make([]Message, 0), maxSize: maxSize}
|
||||
}
|
||||
|
||||
func (ms *MessageStore) Add(msg Message) {
|
||||
ms.mu.Lock()
|
||||
defer ms.mu.Unlock()
|
||||
ms.messages = append(ms.messages, msg)
|
||||
if len(ms.messages) > ms.maxSize {
|
||||
ms.messages = ms.messages[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *MessageStore) GetAll() []Message {
|
||||
ms.mu.RLock()
|
||||
defer ms.mu.RUnlock()
|
||||
result := make([]Message, len(ms.messages))
|
||||
copy(result, ms.messages)
|
||||
return result
|
||||
}
|
||||
|
||||
func (ms *MessageStore) GetBySubject(subject string) []Message {
|
||||
ms.mu.RLock()
|
||||
defer ms.mu.RUnlock()
|
||||
var result []Message
|
||||
for _, msg := range ms.messages {
|
||||
if msg.Subject == subject {
|
||||
result = append(result, msg)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (ms *MessageStore) GetSubjects() []string {
|
||||
ms.mu.RLock()
|
||||
defer ms.mu.RUnlock()
|
||||
subjectMap := make(map[string]bool)
|
||||
for _, msg := range ms.messages {
|
||||
subjectMap[msg.Subject] = true
|
||||
}
|
||||
subjects := make([]string, 0, len(subjectMap))
|
||||
for subject := range subjectMap {
|
||||
subjects = append(subjects, subject)
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
clients map[*websocket.Conn]bool
|
||||
broadcast chan Message
|
||||
register chan *websocket.Conn
|
||||
unregister chan *websocket.Conn
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
broadcast: make(chan Message, 256),
|
||||
register: make(chan *websocket.Conn),
|
||||
unregister: make(chan *websocket.Conn),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case conn := <-h.register:
|
||||
h.clients[conn] = true
|
||||
case conn := <-h.unregister:
|
||||
if _, ok := h.clients[conn]; ok {
|
||||
delete(h.clients, conn)
|
||||
conn.Close()
|
||||
}
|
||||
case message := <-h.broadcast:
|
||||
for conn := range h.clients {
|
||||
err := conn.WriteJSON(message)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket error: %v", err)
|
||||
delete(h.clients, conn)
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
NatsURL string `yaml:"nats_url"`
|
||||
Subjects string `yaml:"subjects"`
|
||||
Port string `yaml:"port"`
|
||||
Token string `yaml:"token"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
MaxMessages int `yaml:"max_messages"`
|
||||
}
|
||||
|
||||
func loadConfig(filename string) (*Config, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
if config.NatsURL == "" {
|
||||
config.NatsURL = "nats://localhost:4222"
|
||||
}
|
||||
if config.Subjects == "" {
|
||||
config.Subjects = ">"
|
||||
}
|
||||
if config.Port == "" {
|
||||
config.Port = "8080"
|
||||
}
|
||||
if config.MaxMessages == 0 {
|
||||
config.MaxMessages = 1000
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
configFile := "config.yaml"
|
||||
if len(os.Args) > 1 {
|
||||
configFile = os.Args[1]
|
||||
}
|
||||
config, err := loadConfig(configFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
log.Printf("Loaded configuration from %s", configFile)
|
||||
store := NewMessageStore(config.MaxMessages)
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
var opts []nats.Option
|
||||
if config.Token != "" {
|
||||
opts = append(opts, nats.Token(config.Token))
|
||||
log.Printf("Using token authentication")
|
||||
} else if config.Username != "" || config.Password != "" {
|
||||
opts = append(opts, nats.UserInfo(config.Username, config.Password))
|
||||
log.Printf("Using username/password authentication")
|
||||
}
|
||||
nc, err := nats.Connect(config.NatsURL, opts...)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to NATS: %v", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
log.Printf("Connected to NATS at %s", config.NatsURL)
|
||||
subjectsList := parseSubjects(config.Subjects)
|
||||
for _, subject := range subjectsList {
|
||||
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
message := Message{
|
||||
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
Subject: msg.Subject,
|
||||
Data: string(msg.Data),
|
||||
Timestamp: time.Now(),
|
||||
Size: len(msg.Data),
|
||||
}
|
||||
store.Add(message)
|
||||
hub.broadcast <- message
|
||||
log.Printf("Received message on %s: %d bytes", msg.Subject, len(msg.Data))
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to subscribe to %s: %v", subject, err)
|
||||
}
|
||||
log.Printf("Subscribed to: %s", subject)
|
||||
defer sub.Unsubscribe()
|
||||
}
|
||||
// Определяем путь к index.html
|
||||
indexPath := "index.html"
|
||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||
// Если не найден в текущей директории, пробуем рядом с исполняемым файлом
|
||||
exePath, err := os.Executable()
|
||||
if err == nil {
|
||||
exeDir := filepath.Dir(exePath)
|
||||
indexPath = filepath.Join(exeDir, "index.html")
|
||||
}
|
||||
}
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, indexPath)
|
||||
})
|
||||
http.HandleFunc("/api/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
subject := r.URL.Query().Get("subject")
|
||||
var messages []Message
|
||||
if subject != "" {
|
||||
messages = store.GetBySubject(subject)
|
||||
} else {
|
||||
messages = store.GetAll()
|
||||
}
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
})
|
||||
http.HandleFunc("/api/subjects", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(store.GetSubjects())
|
||||
})
|
||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
hub.register <- conn
|
||||
|
||||
// Отправляем все существующие сообщения одним массивом при подключении
|
||||
go func() {
|
||||
messages := store.GetAll()
|
||||
// Отправляем массив всех сообщений
|
||||
if err := conn.WriteJSON(messages); err != nil {
|
||||
log.Printf("Error sending initial messages: %v", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Читаем сообщения от клиента (для keep-alive)
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
hub.unregister <- conn
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
log.Printf("Starting HTTP server on port %s", config.Port)
|
||||
log.Printf("Open http://localhost:%s in your browser", config.Port)
|
||||
addr := ":" + config.Port
|
||||
log.Fatal(http.ListenAndServe(addr, nil))
|
||||
}
|
||||
|
||||
func parseSubjects(subjectsStr string) []string {
|
||||
if subjectsStr == ">" {
|
||||
return []string{">"}
|
||||
}
|
||||
var result []string
|
||||
subjects := []rune(subjectsStr)
|
||||
current := ""
|
||||
for i, r := range subjects {
|
||||
if r == ',' {
|
||||
if current != "" {
|
||||
result = append(result, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += string(r)
|
||||
}
|
||||
if i == len(subjects)-1 && current != "" {
|
||||
result = append(result, current)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
result = []string{">"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user