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