Compare commits
122 Commits
b99f60c7e5
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5214f183ff | |||
| 56db916f29 | |||
| 0a2d41d04e | |||
| a70791898c | |||
| 26323dfd15 | |||
| 7d2f3d0f3a | |||
| 255fe2eaf3 | |||
| 6d6dd91241 | |||
| 915aa7018a | |||
| eb8aef11a4 | |||
| 4a00c95d25 | |||
| e9fdaf8711 | |||
| 413e31c711 | |||
| c175461634 | |||
| f26fa3da69 | |||
| 5b90447984 | |||
| 247505a310 | |||
| ad9d567d2c | |||
| c6c46aee68 | |||
| 9f6defd25c | |||
| 2714bd1178 | |||
| 7aa25b02c5 | |||
| 5f6c4303db | |||
| 17d4770de6 | |||
| 337e5891f3 | |||
| 2bc3da21fd | |||
| d79e9dd829 | |||
| a4b7024bb8 | |||
| 87f3836657 | |||
| c2e8037560 | |||
| 54e8102a51 | |||
| 5ccb752836 | |||
| 2616669ab1 | |||
| 71a8fa154b | |||
| d6512d6c97 | |||
| b1e6775f1b | |||
| 8226429b5b | |||
| add1242b97 | |||
| 5475912365 | |||
| b86c36d996 | |||
| 6eacc79445 | |||
| f14490c076 | |||
| 1f6908900b | |||
| 178c3b53f7 | |||
| 534d6aa738 | |||
| aae27fa5e0 | |||
| 5073cfd357 | |||
| 3e5e4815d9 | |||
| f71a3b1a03 | |||
| e024f91111 | |||
| 8f5558fdb7 | |||
| 07066ec8c0 | |||
| 31eecf4ba5 | |||
| cf6065b55a | |||
| 428140ff15 | |||
| 7be99f8e91 | |||
| 43ea41f633 | |||
| b516a54c17 | |||
| 6b82c99d50 | |||
| c73035019f | |||
| e3fae7a02c | |||
| d46d0f8253 | |||
| bcca8fa298 | |||
| 1e4e65bb84 | |||
| 3389df740c | |||
| d535831fc1 | |||
| f8c413a498 | |||
| 134777de10 | |||
| 400ceab47c | |||
| 4ea1aec6e2 | |||
| 1d75935a08 | |||
| c6a9907822 | |||
| 0f8b148279 | |||
| fe7e41e4af | |||
| 81d8f71937 | |||
| a71fde67e4 | |||
| 69ff617c30 | |||
| 398c688fed | |||
| 3430070df8 | |||
| 958211198c | |||
| 78f35f6811 | |||
| 0660117c07 | |||
| 9ede6257f8 | |||
| f5b9b32a9f | |||
| e721cff3f8 | |||
| 7e54d62170 | |||
| 55cb214458 | |||
| 8175d7b3a5 | |||
| 822f953698 | |||
| e7f1ea2386 | |||
| aac3fa3758 | |||
| 26ca7c0d51 | |||
| dd921e5892 | |||
| eedc9c9b62 | |||
| 477dd94227 | |||
| 4f69e002c6 | |||
| 5209e8b2e9 | |||
| 95a6902dae | |||
| adbb0ee368 | |||
| 96f82b4162 | |||
| ed439656f8 | |||
| c59d122e04 | |||
| d62205b329 | |||
| ad92439770 | |||
| 11cef95929 | |||
| f1fc52bd6b | |||
| 24cc11bc8d | |||
| 10d899b50f | |||
| 2a8faaa9fe | |||
| 43e16b1360 | |||
| c5e35b4c12 | |||
| f578b6eb51 | |||
| f537f1eab9 | |||
| a2c71da3a0 | |||
| 9d1096a9b4 | |||
| 57b43da2e3 | |||
| 28631865c8 | |||
| edb1458806 | |||
| 691e1fced5 | |||
| ce73e915ca | |||
| baaa27005e | |||
| 84807b9ba9 |
@@ -0,0 +1,32 @@
|
|||||||
|
name: release-agent
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Go setup
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26.1"
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
workdir: agent
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||||
@@ -1 +1,358 @@
|
|||||||
# HellreigN
|
# HellreigN
|
||||||
|
|
||||||
|
Агент внутренней диагностики инфраструктуры. Централизованный сбор логов, мониторинг нагрузки, управление скриптами и контроль состояния сервисов.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Сбор логов** — journald, Docker, Kubernetes, файлы
|
||||||
|
- **Метрики нагрузки** — CPU, RAM, диск, сеть в реальном времени
|
||||||
|
- **Контроль сервисов** — проверка alive/dead для systemd и Docker
|
||||||
|
- **Удалённое выполнение команд** — запуск скриптов и команд на агентах
|
||||||
|
- **Граф зависимостей** — определение причин сбоев, порядок запуска
|
||||||
|
- **Офлайн-буфер** — логи не теряются при потере связи
|
||||||
|
- **mTLS** — защищённое соединение между агентом и бэкендом
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Инфраструктура │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ Agent 1 │ │ Agent 2 │ │ Agent N │ │
|
||||||
|
│ │ (хост) │ │ (docker) │ │ (k8s) │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┼──────────────┘ │
|
||||||
|
│ │ gRPC (mTLS) │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ Backend │ ◄── REST API │
|
||||||
|
│ │ :8080 / :9001│ │
|
||||||
|
│ └───────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ ClickHouse │ ◄── Хранилище логов │
|
||||||
|
│ └───────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ HTTP
|
||||||
|
┌───────┴───────┐
|
||||||
|
│ Frontend │
|
||||||
|
│ :3000 │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Go 1.26.1+ (для локальной разработки)
|
||||||
|
|
||||||
|
### Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Поднимутся:
|
||||||
|
- **ClickHouse** — хранилище логов
|
||||||
|
- **Backend** — API (`8080`) + gRPC (`9001`)
|
||||||
|
- **Frontend** — веб-интерфейс (`3000`)
|
||||||
|
- **Agent** — пример агента
|
||||||
|
|
||||||
|
Откройте `http://localhost:3000`. Логин: `admin`, пароль: `admin123`.
|
||||||
|
Что бы агент заработал нужно в веб интрфейсе найти кнопку создать токен получить его и вписать в конфигурацию агента
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && go run ./cmd/main.go
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Agent
|
||||||
|
cd agent && CONFIG_FILE=./config.yml go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
`infra/backend/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
token_db: /var/lib/hellreign/tokens.db
|
||||||
|
clickhouse_host: clickhouse:9000
|
||||||
|
clickhouse_user: default
|
||||||
|
clickhouse_password: testpassword
|
||||||
|
clickhouse_database: hellreign
|
||||||
|
admin:
|
||||||
|
admin_name: Admin
|
||||||
|
admin_last_name: User
|
||||||
|
admin_login: admin
|
||||||
|
admin_password: admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агент
|
||||||
|
|
||||||
|
`infra/agent/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend_url: http://backend:8080
|
||||||
|
grpc_url: backend:9001
|
||||||
|
label: production-server-1
|
||||||
|
registration_token: "token-из-ui"
|
||||||
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
services:
|
||||||
|
# journald + проверка systemd
|
||||||
|
- name: nginx
|
||||||
|
type: journald
|
||||||
|
systemd_unit: nginx.service
|
||||||
|
|
||||||
|
# Docker контейнер
|
||||||
|
- name: redis
|
||||||
|
type: docker
|
||||||
|
|
||||||
|
# Файл
|
||||||
|
- name: myapp
|
||||||
|
type: file
|
||||||
|
path: /var/log/myapp/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле `systemd_unit` опционально. Если указано — агент проверяет `systemctl is-active` и шлёт статус `up`/`down`. Для Docker — `docker inspect {{.State.Running}}`.
|
||||||
|
|
||||||
|
### Граф зависимостей
|
||||||
|
|
||||||
|
`infra/services.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nodes:
|
||||||
|
production-server-1:
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
depends_on: [sshd]
|
||||||
|
sshd:
|
||||||
|
depends_on: []
|
||||||
|
```
|
||||||
|
|
||||||
|
Используется для определения причины сбоев и порядка запуска.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `CONFIG_FILE` | `/etc/hellreign/config.yml` | Путь к YAML конфигу |
|
||||||
|
| `GRAPH_YAML_PATH` | `/etc/hellreign/services.yaml` | Путь к графу сервисов |
|
||||||
|
| `SSL_CERT_DIR` | `/var/lib/hellreign/ssl` | Директория mTLS сертификатов |
|
||||||
|
| `SERVER_SAN_DNS` | `localhost,backend` | SAN DNS сертификата |
|
||||||
|
| `SERVER_SAN_IP` | `127.0.0.1` | SAN IP сертификата |
|
||||||
|
| `GRPC_PORT` | `9001` | Порт gRPC |
|
||||||
|
| `GIN_MODE` | `release` | Режим Gin |
|
||||||
|
|
||||||
|
### Агент
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `CONFIG_FILE` | `/etc/hellreign-agent/config.yml` | Путь к YAML конфигу |
|
||||||
|
| `JOURNALD_LOGDIR` | `/var/log/journal` | Директория journald (ro) |
|
||||||
|
| `BUFFER_DB` | `/var/lib/hellreign-agent/agent_buffer.db` | SQLite буфер |
|
||||||
|
| `IS_DEBUG` | `0` | Debug логи (`1`/`0`) |
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
| Сервис | Порт | Назначение |
|
||||||
|
|--------|------|------------|
|
||||||
|
| Frontend | `3000` | Веб-интерфейс |
|
||||||
|
| Backend HTTP | `8080` | REST API + Swagger |
|
||||||
|
| Backend gRPC | `9001` | gRPC (mTLS) |
|
||||||
|
| ClickHouse HTTP | `8123` | HTTP интерфейс |
|
||||||
|
| ClickHouse Native | `9000` | Native протокол |
|
||||||
|
|
||||||
|
## API примеры
|
||||||
|
|
||||||
|
### Авторизация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"login":"admin","password":"admin123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агенты и метрики
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Список подключённых агентов
|
||||||
|
curl http://localhost:8080/api/v1/agents \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
|
||||||
|
# Метрики нагрузки (CPU, RAM, disk, network)
|
||||||
|
curl http://localhost:8080/api/v1/agents/system-metrics \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Поиск
|
||||||
|
curl "http://localhost:8080/api/v1/logs?service=nginx&level=error" \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
|
||||||
|
# Список сервисов
|
||||||
|
curl http://localhost:8080/api/v1/logs/services \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Скрипты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Дерево
|
||||||
|
curl http://localhost:8080/api/v1/scripts/tree \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
|
||||||
|
# Запуск на агенте
|
||||||
|
curl -X POST http://localhost:8080/api/v1/scripts/1/run \
|
||||||
|
-H "Authorization: Bearer <jwt>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token":"agent-token"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swagger
|
||||||
|
|
||||||
|
`http://localhost:8080/swagger/index.html`
|
||||||
|
|
||||||
|
Перегенерация:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && swag init -g ./cmd/main.go --parseDependency --parseInternal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой агента на хост
|
||||||
|
|
||||||
|
### 1. Директории
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/hellreign-agent/certs /var/lib/hellreign-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Конфиг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/hellreign-agent/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend_url: https://monitoring.example.com
|
||||||
|
grpc_url: monitoring.example.com:9001
|
||||||
|
label: prod-web-1
|
||||||
|
registration_token: "token-из-ui"
|
||||||
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
services:
|
||||||
|
- name: nginx
|
||||||
|
type: journald
|
||||||
|
systemd_unit: nginx.service
|
||||||
|
- name: postgres
|
||||||
|
type: journald
|
||||||
|
systemd_unit: postgresql.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Бинарь
|
||||||
|
|
||||||
|
Скачать из релиза Gitea или собрать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent && go build -o hellreign-agent ./main.go
|
||||||
|
sudo mv hellreign-agent /usr/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Systemd
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=HellreigN Agent
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/hellreign-agent
|
||||||
|
Environment=CONFIG_FILE=/etc/hellreign-agent/config.yml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now hellreign-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
При пуше тега `v*` — GoReleaser собирает `.deb` и `.rpm` для `linux/amd64` и `linux/arm64`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Требует секрет `GITEA_TOKEN` в настройках репозитория.
|
||||||
|
|
||||||
|
## Proto
|
||||||
|
|
||||||
|
После изменений в `proto/hellreign.proto`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd proto
|
||||||
|
protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
|
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
hellreign.proto
|
||||||
|
mv hellreign*.go proto/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
HellreigN/
|
||||||
|
├── agent/ # Агент диагностики
|
||||||
|
│ ├── main.go
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── buffer/ # SQLite буфер (офлайн-доставка)
|
||||||
|
│ ├── client/ # gRPC клиент команд
|
||||||
|
│ ├── commander/ # Исполнитель команд
|
||||||
|
│ ├── config/ # YAML конфиг
|
||||||
|
│ ├── metrics/ # Сбор CPU, RAM, disk, network
|
||||||
|
│ ├── logsource/ # Источники логов
|
||||||
|
│ │ ├── docker/
|
||||||
|
│ │ ├── file/
|
||||||
|
│ │ ├── journald/
|
||||||
|
│ │ └── kubernetes/
|
||||||
|
│ ├── mtls/ # mTLS credentials
|
||||||
|
│ └── registration/ # Регистрация
|
||||||
|
├── backend/ # Бэкенд API
|
||||||
|
│ ├── cmd/main.go
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── handlers/ # HTTP хендлеры
|
||||||
|
│ ├── repository/ # SQLite репозитории
|
||||||
|
│ ├── grpcsrv/
|
||||||
|
│ │ ├── commander/ # Выполнение команд
|
||||||
|
│ │ └── collector/ # Сбор логов и метрик
|
||||||
|
│ ├── auth/ # JWT
|
||||||
|
│ └── storage/ # ClickHouse
|
||||||
|
├── frontend/ # React + TypeScript
|
||||||
|
├── infra/ # Docker Compose
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── services.yaml # Граф зависимостей
|
||||||
|
│ ├── backend/config.yml
|
||||||
|
│ ├── agent/config.yml
|
||||||
|
│ └── clickhouse/init/
|
||||||
|
├── migrations/ # SQL миграции SQLite
|
||||||
|
└── proto/ # Protobuf
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||||
|
version: 2
|
||||||
|
project_name: BanForge
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
gitea_urls:
|
||||||
|
api: https://gitea.d3m0k1d.ru/api/v1
|
||||||
|
download: https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/download
|
||||||
|
skip_tls_verify: false
|
||||||
|
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: banforge
|
||||||
|
main: ./main.go
|
||||||
|
binary: banforge
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
- goos: darwin
|
||||||
|
- goos: freebsd
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- "-s -w"
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
|
||||||
|
nfpms:
|
||||||
|
- id: banforge
|
||||||
|
package_name: banforge
|
||||||
|
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
homepage: https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN
|
||||||
|
description: HellreigN agent
|
||||||
|
maintainer: d3m0k1d <contact@d3m0k1d.ru>
|
||||||
|
license: GPLv3.0
|
||||||
|
formats:
|
||||||
|
- deb
|
||||||
|
- rpm
|
||||||
|
bindir: /usr/bin
|
||||||
|
release:
|
||||||
|
gitea:
|
||||||
|
owner: d3m0k1d
|
||||||
|
name: BanForge
|
||||||
|
mode: keep-existing
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
sort: asc
|
||||||
|
filters:
|
||||||
|
exclude:
|
||||||
|
- "^docs:"
|
||||||
|
- "^test:"
|
||||||
|
checksum:
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
|
||||||
|
algorithm: sha256
|
||||||
|
|
||||||
|
sboms:
|
||||||
|
- artifacts: any
|
||||||
|
documents:
|
||||||
|
- "{{ .ArtifactName }}.spdx.json"
|
||||||
|
cmd: syft
|
||||||
|
args: ["$artifact", "--output", "spdx-json=$document"]
|
||||||
+6
-8
@@ -2,16 +2,14 @@ FROM golang:1.26.1 as builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY proto/ proto/
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
COPY agent/ agent/
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
|
||||||
go mod download
|
|
||||||
|
|
||||||
COPY . .
|
WORKDIR /app/agent
|
||||||
ENV CGO_ENABLED=0
|
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
go build -ldflags "-s -w" -o agent ./main.go
|
go mod download && \
|
||||||
|
CGO_ENABLED=0 go build -ldflags "-s -w" -o /agent .
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -21,6 +19,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/agent .
|
COPY --from=builder /agent .
|
||||||
|
|
||||||
CMD ["./agent"]
|
CMD ["./agent"]
|
||||||
|
|||||||
+73
-2
@@ -3,16 +3,87 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d
|
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260404174628-3389df740c20
|
||||||
|
github.com/hpcloud/tail v1.0.0
|
||||||
|
github.com/moby/moby/api v1.54.1
|
||||||
|
github.com/moby/moby/client v0.4.0
|
||||||
|
github.com/samber/lo v1.53.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
k8s.io/api v0.35.3
|
||||||
|
k8s.io/apimachinery v0.35.3
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
|
golang.org/x/term v0.41.0 // indirect
|
||||||
|
golang.org/x/time v0.11.0 // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||||
|
k8s.io/client-go v0.35.3
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto
|
||||||
|
|||||||
+187
-6
@@ -1,23 +1,127 @@
|
|||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN v0.0.0-20260403210401-a6212c89fc0e/go.mod h1:dXlVaOzoQVl3cOgArvPXcMQsoamUiIi4DHwDp7GtzQU=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403205628-0f9697f6f32e h1:aMvfJ7c0LvxIiyBy9tJDxIkP0daHbW4ZOx+06JgRM90=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403205628-0f9697f6f32e/go.mod h1:1DByetpOnW2+AjM8ZWbJ1Xfzprus8fBie2AMUP/YHHA=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e/go.mod h1:1DByetpOnW2+AjM8ZWbJ1Xfzprus8fBie2AMUP/YHHA=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d h1:oBBLU8/nhXgOr0Z/M/t4pYj3KjuRj8AI15J0RJCiRt8=
|
|
||||||
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d/go.mod h1:FEPB3qn+wXkes/eArIMdq1/3CbHnSDUxsUtXhC8mgOg=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||||
|
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
|
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||||
|
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||||
@@ -28,14 +132,29 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
@@ -44,3 +163,65 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
|||||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
|
||||||
|
k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
|
||||||
|
k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
|
||||||
|
k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||||
|
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
|
||||||
|
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||||
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||||
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||||
|
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||||
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||||
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package buffer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BufferedLog represents a log entry stored for later delivery
|
||||||
|
type BufferedLog struct {
|
||||||
|
ID int64
|
||||||
|
Service string
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBuffer provides SQLite-backed log buffering
|
||||||
|
type LogBuffer struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer creates a new log buffer with the given database path
|
||||||
|
func NewLogBuffer(dbPath string) (*LogBuffer, error) {
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create table if not exists
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS buffered_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index for efficient ordering
|
||||||
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_created_at ON buffered_logs(created_at ASC)`)
|
||||||
|
|
||||||
|
return &LogBuffer{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (b *LogBuffer) Close() error {
|
||||||
|
return b.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store stores a log entry in the buffer
|
||||||
|
func (b *LogBuffer) Store(service, message string) error {
|
||||||
|
_, err := b.db.Exec(
|
||||||
|
"INSERT INTO buffered_logs (service, message) VALUES (?, ?)",
|
||||||
|
service, message,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreBatch stores multiple log entries in a single transaction
|
||||||
|
func (b *LogBuffer) StoreBatch(entries []BufferedLog) error {
|
||||||
|
tx, err := b.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare("INSERT INTO buffered_logs (service, message) VALUES (?, ?)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if _, err := stmt.Exec(entry.Service, entry.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPending retrieves pending logs in order of arrival, limited to batchSize
|
||||||
|
func (b *LogBuffer) GetPending(batchSize int) ([]BufferedLog, error) {
|
||||||
|
rows, err := b.db.Query(
|
||||||
|
"SELECT id, service, message, created_at FROM buffered_logs ORDER BY created_at ASC LIMIT ?",
|
||||||
|
batchSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []BufferedLog
|
||||||
|
for rows.Next() {
|
||||||
|
var log BufferedLog
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&log.ID, &log.Service, &log.Message, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
logs = append(logs, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a log entry from the buffer after successful delivery
|
||||||
|
func (b *LogBuffer) Delete(id int64) error {
|
||||||
|
_, err := b.db.Exec("DELETE FROM buffered_logs WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBatch removes multiple log entries after successful delivery
|
||||||
|
func (b *LogBuffer) DeleteBatch(ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := b.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := tx.Exec("DELETE FROM buffered_logs WHERE id = ?", id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of buffered logs
|
||||||
|
func (b *LogBuffer) Count() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := b.db.QueryRow("SELECT COUNT(*) FROM buffered_logs").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all buffered logs
|
||||||
|
func (b *LogBuffer) Clear() error {
|
||||||
|
_, err := b.db.Exec("DELETE FROM buffered_logs")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushToJSON exports buffered logs to JSON format for debugging
|
||||||
|
func (b *LogBuffer) FlushToJSON() ([]byte, error) {
|
||||||
|
logs, err := b.GetPending(1000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.MarshalIndent(logs, "", " ")
|
||||||
|
}
|
||||||
@@ -13,26 +13,29 @@ import (
|
|||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommanderClient struct {
|
type CommanderClient struct {
|
||||||
cmder *commander.Commander
|
cmder *commander.CommandExecutor
|
||||||
wg *sync.WaitGroup
|
wg *sync.WaitGroup
|
||||||
|
id, label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
cmder *commander.Commander,
|
cmder *commander.CommandExecutor,
|
||||||
wg *sync.WaitGroup,
|
id, label string,
|
||||||
) CommanderClient {
|
) CommanderClient {
|
||||||
return CommanderClient{cmder, wg}
|
return CommanderClient{cmder, new(sync.WaitGroup), id, label}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *CommanderClient) HandleCommands(ctx context.Context, srvAddr string, tc credentials.TransportCredentials) error {
|
func (self *CommanderClient) HandleCommands(ctx context.Context, srvAddr string, tc credentials.TransportCredentials) error {
|
||||||
cli, err := grpc.NewClient(srvAddr, grpc.WithTransportCredentials(tc))
|
cli, err := grpc.NewClient(srvAddr, grpc.WithTransportCredentials(tc))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to connect to gRPC: %w", err)
|
return fmt.Errorf("Failed to connect to gRPC: %w", err)
|
||||||
}
|
}
|
||||||
ccli := proto.NewCommanderClient(cli)
|
ccli := proto.NewCommanderClient(cli)
|
||||||
bidi, err := ccli.Stream(ctx)
|
bidi, err := ccli.Stream(metadata.NewOutgoingContext(ctx, metadata.MD{"agentid": []string{self.id}, "label": []string{self.label}}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,6 @@ func (self *CommanderClient) recv(bidi grpc.BidiStreamingClient[proto.FinishedCo
|
|||||||
}
|
}
|
||||||
self.wg.Go(func() {
|
self.wg.Go(func() {
|
||||||
func() error {
|
func() error {
|
||||||
|
|
||||||
fc, err := self.cmder.Execute(msg)
|
fc, err := self.cmder.Execute(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -3,22 +3,19 @@ package commander
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"os/exec"
|
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Commander struct {
|
type CommandExecutor struct{}
|
||||||
}
|
|
||||||
|
|
||||||
func (*Commander) Execute(command *proto.Command) (*proto.FinishedCommand, error) {
|
func (*CommandExecutor) Execute(command *proto.Command) (fc *proto.FinishedCommand, err error) {
|
||||||
|
fc = new(proto.FinishedCommand)
|
||||||
|
fc.Id = command.Id
|
||||||
cmd := exec.Command(command.Command[0], command.Command[1:]...)
|
cmd := exec.Command(command.Command[0], command.Command[1:]...)
|
||||||
var (
|
var stdin io.WriteCloser
|
||||||
stdin io.WriteCloser
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if command.Stdin != nil {
|
if command.Stdin != nil {
|
||||||
stdin, err = cmd.StdinPipe()
|
stdin, err = cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,16 +47,20 @@ func (*Commander) Execute(command *proto.Command) (*proto.FinishedCommand, error
|
|||||||
_, err := io.Copy(stderrbuf, stderr)
|
_, err := io.Copy(stderrbuf, stderr)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err := cmd.Wait(); err != nil {
|
if waitErr := cmd.Wait(); waitErr != nil {
|
||||||
return nil, err
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(waitErr, &exitErr) {
|
||||||
|
return nil, waitErr
|
||||||
|
}
|
||||||
|
fc.Status = int32(exitErr.ExitCode())
|
||||||
|
} else {
|
||||||
|
fc.Status = int32(cmd.ProcessState.ExitCode())
|
||||||
}
|
}
|
||||||
if err := eg.Wait(); err != nil {
|
if err := eg.Wait(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &proto.FinishedCommand{
|
fc.Status = int32(cmd.ProcessState.ExitCode())
|
||||||
Id: command.Id,
|
fc.Stdout = stdoutbuf.String()
|
||||||
Status: int32(cmd.ProcessState.ExitCode()),
|
fc.Stderr = stderrbuf.String()
|
||||||
Stdout: stdoutbuf.String(),
|
return
|
||||||
Stderr: stderrbuf.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Path *string `yaml:"path"`
|
||||||
|
SystemdUnit *string `yaml:"systemd_unit"` // Optional: systemd unit name for health check
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentConfig struct {
|
||||||
|
BackendURL string `yaml:"backend_url"`
|
||||||
|
GRPCURL string `yaml:"grpc_url"`
|
||||||
|
RegistrationToken string `yaml:"registration_token"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
CertDir string `yaml:"cert_dir"`
|
||||||
|
Services []ServiceConfig `yaml:"services"`
|
||||||
|
|
||||||
|
MonitorDocker bool `yaml:"monitor_docker"`
|
||||||
|
|
||||||
|
MonitorKubernetes bool `yaml:"monitor_kubes"`
|
||||||
|
KubernetesNamespace *string `yaml:"kubernetes_namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*AgentConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg AgentConfig
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.CertDir == "" {
|
||||||
|
cfg.CertDir = "/etc/hellreign-agent/certs"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadFromString(data string) (*AgentConfig, error) {
|
||||||
|
var cfg AgentConfig
|
||||||
|
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfigPath(path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("config file not found: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
*slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(debug bool) *Logger {
|
||||||
|
var level slog.Level
|
||||||
|
if debug {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
} else {
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Logger{
|
||||||
|
Logger: slog.New(handler),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(DockerLogSource)
|
||||||
|
|
||||||
|
// DockerLogSource reads logs from a Docker container via `docker logs -f`.
|
||||||
|
type DockerLogSource struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stdoutscanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine implements logsource.LogSource.
|
||||||
|
func (d *DockerLogSource) ReadLine() (string, error) {
|
||||||
|
if d.stdoutscanner.Scan() {
|
||||||
|
return d.stdoutscanner.Text(), nil
|
||||||
|
} else {
|
||||||
|
if d.stdoutscanner.Err() == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||||
|
}
|
||||||
|
return "", d.stdoutscanner.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements logsource.LogSource.
|
||||||
|
func (d *DockerLogSource) Close() error {
|
||||||
|
_ = d.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
return d.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Docker log source for the given container.
|
||||||
|
// The container name is taken from cfg.Path (if set) or cfg.Name.
|
||||||
|
func New(cfg config.ServiceConfig) (*DockerLogSource, error) {
|
||||||
|
containerName := cfg.Name
|
||||||
|
if cfg.Path != nil && *cfg.Path != "" {
|
||||||
|
containerName = *cfg.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker logs -f --tail=0 --no-color <container_name>
|
||||||
|
// -f : follow new logs
|
||||||
|
// --tail=0 : skip existing logs
|
||||||
|
// --no-color: strip color codes for clean output
|
||||||
|
cmd := exec.Command("docker", "logs", "-f", "--tail=0", "--no-color", containerName) //nolint:gosec
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stdout pipe for docker logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also capture stderr since docker logs merges stdout and stderr from the container
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stderr pipe for docker logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start docker logs for container %q: %w", containerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use MultiReader to merge stdout and stderr
|
||||||
|
// Docker logs outputs container stdout+stderr to its own stdout, but we also
|
||||||
|
// capture the docker CLI's stderr separately in case of errors (e.g. container not found)
|
||||||
|
stdoutscanner := bufio.NewScanner(stdout)
|
||||||
|
|
||||||
|
// Start a goroutine to consume stderr (we don't send docker CLI stderr as logs,
|
||||||
|
// but we need to prevent the pipe from filling up)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
_, err := stderr.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &DockerLogSource{cmd, stdout, stdoutscanner}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
"github.com/hpcloud/tail"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(FileLogSource)
|
||||||
|
|
||||||
|
type FileLogSource struct {
|
||||||
|
*tail.Tail
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(filepath string) (fls *FileLogSource, err error) {
|
||||||
|
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(filepath, []byte{}, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, err := tail.TailFile(filepath, tail.Config{
|
||||||
|
Follow: true,
|
||||||
|
Location: &tail.SeekInfo{
|
||||||
|
Offset: 100,
|
||||||
|
Whence: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return &FileLogSource{t}, nil
|
||||||
|
}
|
||||||
|
func (f *FileLogSource) ReadLine() (string, error) {
|
||||||
|
select {
|
||||||
|
case <-f.Dead():
|
||||||
|
return "", errors.Join(logsource.ErrDead, f.Err())
|
||||||
|
case line := <-f.Lines:
|
||||||
|
return line.Text, line.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (f *FileLogSource) Close() error {
|
||||||
|
return f.Stop()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package logsource
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
type LogSource interface {
|
||||||
|
ReadLine() (string, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrDead = errors.New("shouldn't continue to read that")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package journald
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(JournaldLogSource)
|
||||||
|
|
||||||
|
type JournaldLogSource struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stdoutscanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine implements logsource.LogSource.
|
||||||
|
func (j *JournaldLogSource) ReadLine() (string, error) {
|
||||||
|
if j.stdoutscanner.Scan() {
|
||||||
|
return j.stdoutscanner.Text(), nil
|
||||||
|
} else {
|
||||||
|
if j.stdoutscanner.Err() == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||||
|
}
|
||||||
|
return "", j.stdoutscanner.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (j *JournaldLogSource) Close() error {
|
||||||
|
_ = j.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
return j.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ServiceConfig, logdir string) (*JournaldLogSource, error) {
|
||||||
|
args := make([]string, 0)
|
||||||
|
if cfg.Path != nil {
|
||||||
|
args = append(args, "-u", *cfg.Path)
|
||||||
|
}
|
||||||
|
args = append(args, "-f", "-n", "0", "-o", "short", "--no-pager", "--directory", logdir)
|
||||||
|
cmd := exec.Command("journalctl", args...) //nolint:gosec
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stdoutscanner := bufio.NewScanner(stdout)
|
||||||
|
return &JournaldLogSource{cmd, stdout, stdoutscanner}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(KubernetesLogSource)
|
||||||
|
|
||||||
|
// KubernetesLogSource reads logs from a Kubernetes pod via `kubectl logs -f`.
|
||||||
|
type KubernetesLogSource struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stdoutscanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine implements logsource.LogSource.
|
||||||
|
func (k *KubernetesLogSource) ReadLine() (string, error) {
|
||||||
|
if k.stdoutscanner.Scan() {
|
||||||
|
return k.stdoutscanner.Text(), nil
|
||||||
|
} else {
|
||||||
|
if k.stdoutscanner.Err() == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||||
|
}
|
||||||
|
return "", k.stdoutscanner.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements logsource.LogSource.
|
||||||
|
func (k *KubernetesLogSource) Close() error {
|
||||||
|
_ = k.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
return k.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Kubernetes log source for the given pod.
|
||||||
|
// The pod identifier is taken from cfg.Path in the format "namespace/podname".
|
||||||
|
// If no namespace is specified (just "podname"), "default" namespace is used.
|
||||||
|
// If cfg.Path is nil or empty, cfg.Name is used as the pod name with "default" namespace.
|
||||||
|
func New(cfg config.ServiceConfig) (*KubernetesLogSource, error) {
|
||||||
|
podName := cfg.Name
|
||||||
|
namespace := "default"
|
||||||
|
|
||||||
|
if cfg.Path != nil && *cfg.Path != "" {
|
||||||
|
parts := strings.SplitN(*cfg.Path, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
namespace = parts[0]
|
||||||
|
podName = parts[1]
|
||||||
|
} else {
|
||||||
|
podName = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kubectl logs -f <pod> -n <namespace> --tail=0 --no-color
|
||||||
|
// -f : follow new logs
|
||||||
|
// --tail=0 : skip existing logs
|
||||||
|
// --no-color: strip color codes for clean output
|
||||||
|
cmd := exec.Command("kubectl", "logs", "-f", podName, "-n", namespace, "--tail=0", "--no-color") //nolint:gosec
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stdout pipe for kubectl logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stderr pipe for kubectl logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start kubectl logs for pod %q (ns: %q): %w", podName, namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutscanner := bufio.NewScanner(stdout)
|
||||||
|
|
||||||
|
// Consume stderr to prevent pipe from filling up
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
_, err := stderr.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &KubernetesLogSource{cmd, stdout, stdoutscanner}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemMetrics holds current system resource usage.
|
||||||
|
type SystemMetrics struct {
|
||||||
|
CPUPercent float64
|
||||||
|
MemoryPercent float64
|
||||||
|
DiskPercent float64
|
||||||
|
NetworkRxBytes float64
|
||||||
|
NetworkTxBytes float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collector collects system metrics from /proc and sysfs.
|
||||||
|
type Collector struct {
|
||||||
|
lastCPUTotal uint64
|
||||||
|
lastCPUIdle uint64
|
||||||
|
lastNetRx float64
|
||||||
|
lastNetTx float64
|
||||||
|
lastNetTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCollector creates a new metrics collector.
|
||||||
|
func NewCollector() *Collector {
|
||||||
|
return &Collector{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect gathers current system metrics.
|
||||||
|
func (c *Collector) Collect() (SystemMetrics, error) {
|
||||||
|
var m SystemMetrics
|
||||||
|
|
||||||
|
cpu, err := c.readCPU()
|
||||||
|
if err == nil {
|
||||||
|
m.CPUPercent = cpu
|
||||||
|
}
|
||||||
|
|
||||||
|
mem, err := c.readMemory()
|
||||||
|
if err == nil {
|
||||||
|
m.MemoryPercent = mem
|
||||||
|
}
|
||||||
|
|
||||||
|
disk, err := c.readDisk("/")
|
||||||
|
if err == nil {
|
||||||
|
m.DiskPercent = disk
|
||||||
|
}
|
||||||
|
|
||||||
|
netRx, netTx, err := c.readNetwork()
|
||||||
|
if err == nil {
|
||||||
|
m.NetworkRxBytes = netRx
|
||||||
|
m.NetworkTxBytes = netTx
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCPU returns CPU usage percentage since last call.
|
||||||
|
func (c *Collector) readCPU() (float64, error) {
|
||||||
|
f, err := os.Open("/proc/stat")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if !strings.HasPrefix(line, "cpu ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 8 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var user, nice, system, idle, iowait, irq, softirq uint64
|
||||||
|
user, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
nice, _ = strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
system, _ = strconv.ParseUint(fields[3], 10, 64)
|
||||||
|
idle, _ = strconv.ParseUint(fields[4], 10, 64)
|
||||||
|
iowait, _ = strconv.ParseUint(fields[5], 10, 64)
|
||||||
|
irq, _ = strconv.ParseUint(fields[6], 10, 64)
|
||||||
|
softirq, _ = strconv.ParseUint(fields[7], 10, 64)
|
||||||
|
|
||||||
|
total := user + nice + system + idle + iowait + irq + softirq
|
||||||
|
idleTotal := idle + iowait
|
||||||
|
|
||||||
|
if c.lastCPUTotal > 0 {
|
||||||
|
totalDiff := total - c.lastCPUTotal
|
||||||
|
idleDiff := idleTotal - c.lastCPUIdle
|
||||||
|
|
||||||
|
if totalDiff > 0 {
|
||||||
|
cpuPercent := float64(totalDiff-idleDiff) / float64(totalDiff) * 100.0
|
||||||
|
c.lastCPUTotal = total
|
||||||
|
c.lastCPUIdle = idleTotal
|
||||||
|
return cpuPercent, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastCPUTotal = total
|
||||||
|
c.lastCPUIdle = idleTotal
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMemory returns RAM usage percentage.
|
||||||
|
func (c *Collector) readMemory() (float64, error) {
|
||||||
|
f, err := os.Open("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var total, available uint64
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "MemTotal:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
total, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
} else if strings.HasPrefix(line, "MemAvailable:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
available, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
used := total - available
|
||||||
|
return float64(used) / float64(total) * 100.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDisk returns disk usage percentage for the given path.
|
||||||
|
func (c *Collector) readDisk(path string) (float64, error) {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total := stat.Blocks * uint64(stat.Bsize)
|
||||||
|
free := stat.Bfree * uint64(stat.Bsize)
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
used := total - free
|
||||||
|
return float64(used) / float64(total) * 100.0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readNetwork returns network RX/TX bytes per second.
|
||||||
|
func (c *Collector) readNetwork() (float64, float64, error) {
|
||||||
|
f, err := os.Open("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var totalRx, totalTx uint64
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Skip header lines
|
||||||
|
if strings.Contains(line, "|") || strings.HasPrefix(strings.TrimSpace(line), "Inter") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(line), ":", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(parts[1])
|
||||||
|
if len(fields) < 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rx, _ := strconv.ParseUint(fields[0], 10, 64)
|
||||||
|
tx, _ := strconv.ParseUint(fields[8], 10, 64)
|
||||||
|
totalRx += rx
|
||||||
|
totalTx += tx
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var rxRate, txRate float64
|
||||||
|
|
||||||
|
if !c.lastNetTime.IsZero() {
|
||||||
|
elapsed := now.Sub(c.lastNetTime).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
rxRate = float64(totalRx) - c.lastNetRx
|
||||||
|
txRate = float64(totalTx) - c.lastNetTx
|
||||||
|
// Convert to bytes per second
|
||||||
|
rxRate = rxRate / elapsed
|
||||||
|
txRate = txRate / elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.lastNetRx = float64(totalRx)
|
||||||
|
c.lastNetTx = float64(totalTx)
|
||||||
|
c.lastNetTime = now
|
||||||
|
|
||||||
|
return rxRate, txRate, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ServiceStatus represents the unified status of a service across all monitor types.
|
||||||
|
type ServiceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusRunning ServiceStatus = "running"
|
||||||
|
StatusStopped ServiceStatus = "stopped"
|
||||||
|
StatusDegraded ServiceStatus = "degraded"
|
||||||
|
StatusPending ServiceStatus = "pending"
|
||||||
|
StatusUnknown ServiceStatus = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsHealthy reports whether the service is stable enough for dependents to rely on.
|
||||||
|
func (s ServiceStatus) IsHealthy() bool {
|
||||||
|
return s == StatusRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Name string
|
||||||
|
Status ServiceStatus
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/monitor"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
|
moby "github.com/moby/moby/client"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ monitor.ServiceMonitor = new(DockerMonitor)
|
||||||
|
|
||||||
|
type DockerMonitor struct{}
|
||||||
|
|
||||||
|
func New() *DockerMonitor {
|
||||||
|
return &DockerMonitor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *DockerMonitor) CheckServices(ctx context.Context) ([]models.Service, error) {
|
||||||
|
client, err := moby.New(moby.FromEnv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctrs, err := client.ContainerList(ctx, moby.ContainerListOptions{
|
||||||
|
Size: false,
|
||||||
|
All: false,
|
||||||
|
Limit: 0,
|
||||||
|
Filters: moby.Filters{},
|
||||||
|
Latest: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return lo.Map(ctrs.Items, func(item container.Summary, _ int) models.Service {
|
||||||
|
return models.Service{
|
||||||
|
Name: lo.If(len(item.Names) > 0, item.Names[0]).Else(item.ID),
|
||||||
|
Status: mapContainerState(string(item.State)),
|
||||||
|
}
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapContainerState maps Docker container states to unified ServiceStatus.
|
||||||
|
func mapContainerState(state string) models.ServiceStatus {
|
||||||
|
switch state {
|
||||||
|
case "running":
|
||||||
|
return models.StatusRunning
|
||||||
|
case "exited", "dead":
|
||||||
|
return models.StatusStopped
|
||||||
|
case "paused":
|
||||||
|
return models.StatusDegraded
|
||||||
|
case "restarting", "created", "removing":
|
||||||
|
return models.StatusPending
|
||||||
|
default:
|
||||||
|
return models.StatusUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceMonitor interface {
|
||||||
|
CheckServices(ctx context.Context) ([]models.Service, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package kubes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ monitor.ServiceMonitor = new(KubesMonitor)
|
||||||
|
|
||||||
|
type KubesMonitor struct{ namespace string }
|
||||||
|
|
||||||
|
func New(namespace string) *KubesMonitor {
|
||||||
|
return &KubesMonitor{namespace}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *KubesMonitor) CheckServices(ctx context.Context) ([]models.Service, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
config, err := clientcmd.BuildConfigFromFlags("", filepath.Join(home, ".kube", "config"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// TODO: consider moving all the shit above into constructor
|
||||||
|
pods, err := clientset.CoreV1().Pods(self.namespace).List(context.TODO(), metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return lo.Map(pods.Items, func(item corev1.Pod, _ int) models.Service {
|
||||||
|
return models.Service{
|
||||||
|
Name: item.Name,
|
||||||
|
Status: mapPodPhase(item.Status.Phase),
|
||||||
|
}
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapPodPhase maps K8s pod phases to unified ServiceStatus.
|
||||||
|
func mapPodPhase(phase corev1.PodPhase) models.ServiceStatus {
|
||||||
|
switch phase {
|
||||||
|
case corev1.PodRunning:
|
||||||
|
return models.StatusRunning
|
||||||
|
case corev1.PodSucceeded:
|
||||||
|
return models.StatusStopped
|
||||||
|
case corev1.PodFailed:
|
||||||
|
return models.StatusStopped
|
||||||
|
case corev1.PodPending:
|
||||||
|
return models.StatusPending
|
||||||
|
default:
|
||||||
|
return models.StatusUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package mtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadMTLSCredentials loads client certificate and CA certificate for mTLS.
|
||||||
|
func LoadMTLSCredentials(caCertPEM, clientCertPEM, clientKeyPEM []byte) (credentials.TransportCredentials, error) {
|
||||||
|
cert, err := tls.X509KeyPair(clientCertPEM, clientKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load client key pair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
if !caCertPool.AppendCertsFromPEM(caCertPEM) {
|
||||||
|
return nil, fmt.Errorf("failed to append CA certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
RootCAs: caCertPool,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials.NewTLS(tlsConfig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMTLSCredentialsFromFiles loads mTLS credentials from file paths.
|
||||||
|
func LoadMTLSCredentialsFromFiles(caCertPath, clientCertPath, clientKeyPath string) (credentials.TransportCredentials, error) {
|
||||||
|
caCert, err := os.ReadFile(caCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||||
|
}
|
||||||
|
clientCert, err := os.ReadFile(clientCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read client cert: %w", err)
|
||||||
|
}
|
||||||
|
clientKey, err := os.ReadFile(clientKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read client key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoadMTLSCredentials(caCert, clientCert, clientKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package registration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Certs struct {
|
||||||
|
CACertPEM []byte
|
||||||
|
ClientCertPEM []byte
|
||||||
|
ClientKeyPEM []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
ClientCert string `json:"client_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyAndCSR generates a new ECDSA private key and CSR for the agent.
|
||||||
|
func GenerateKeyAndCSR(label string) (*ecdsa.PrivateKey, []byte, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
template := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: label,
|
||||||
|
Organization: []string{"HellreigN Agent"},
|
||||||
|
},
|
||||||
|
SignatureAlgorithm: x509.ECDSAWithSHA256,
|
||||||
|
}
|
||||||
|
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("create csr: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrDER,
|
||||||
|
})
|
||||||
|
|
||||||
|
return key, csrPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register sends CSR to backend and receives signed certificates.
|
||||||
|
func Register(backendURL, token string, csrPEM []byte) (*Certs, error) {
|
||||||
|
reqBody := RegisterRequest{CSR: string(csrPEM), Token: token}
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/v1/agents/register", backendURL)
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("register request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||||
|
return nil, fmt.Errorf("registration failed (status %d): %s", resp.StatusCode, errResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var regResp RegisterResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Certs{
|
||||||
|
CACertPEM: []byte(regResp.CACert),
|
||||||
|
ClientCertPEM: []byte(regResp.ClientCert),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCerts saves CA cert, client cert, and client key to the given directory.
|
||||||
|
func SaveCerts(certDir string, certs *Certs, key *ecdsa.PrivateKey) error {
|
||||||
|
if err := os.MkdirAll(certDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("create cert dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certs.CACertPEM, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(certDir, "client.crt"), certs.ClientCertPEM, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "EC PRIVATE KEY",
|
||||||
|
Bytes: keyDER,
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(filepath.Join(certDir, "client.key"), keyPEM, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCerts loads existing certificates and key from disk.
|
||||||
|
func LoadCerts(certDir string) (*Certs, *ecdsa.PrivateKey, error) {
|
||||||
|
caCert, err := os.ReadFile(filepath.Join(certDir, "ca.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
clientCert, err := os.ReadFile(filepath.Join(certDir, "client.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
clientKeyPEM, err := os.ReadFile(filepath.Join(certDir, "client.key"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(clientKeyPEM)
|
||||||
|
if block == nil {
|
||||||
|
return nil, nil, fmt.Errorf("decode client key")
|
||||||
|
}
|
||||||
|
key, err := x509.ParseECPrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse client key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Certs{
|
||||||
|
CACertPEM: caCert,
|
||||||
|
ClientCertPEM: clientCert,
|
||||||
|
}, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertsExist checks if all certificate files exist in the directory.
|
||||||
|
func CertsExist(certDir string) bool {
|
||||||
|
files := []string{"ca.crt", "client.crt", "client.key"}
|
||||||
|
for _, f := range files {
|
||||||
|
if _, err := os.Stat(filepath.Join(certDir, f)); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+479
@@ -1 +1,480 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/buffer"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/client"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
agentmetrics "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/metrics"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/docker"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/kubernetes"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := os.Getenv("CONFIG_FILE")
|
||||||
|
if cfgPath == "" {
|
||||||
|
cfgPath = "/etc/hellreign-agent/config.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lgr := logger.New(os.Getenv("IS_DEBUG") == "1")
|
||||||
|
lgr.Debug("Config parsed", "cfg", cfg)
|
||||||
|
|
||||||
|
// Check if certificates already exist (agent was previously registered)
|
||||||
|
if registration.CertsExist(cfg.CertDir) {
|
||||||
|
lgr.Info("Certificates found, skipping registration")
|
||||||
|
} else {
|
||||||
|
if cfg.RegistrationToken == "" {
|
||||||
|
lgr.Error("No registration token provided")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate key and CSR
|
||||||
|
k, csrPEM, err := registration.GenerateKeyAndCSR(cfg.Label)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to generate key and CSR", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
lgr.Info("Generated ECDSA key pair and CSR")
|
||||||
|
|
||||||
|
// Register with backend
|
||||||
|
certs, err := registration.Register(cfg.BackendURL, cfg.RegistrationToken, csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to register", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
lgr.Info("Successfully registered, received certificates")
|
||||||
|
|
||||||
|
// Save certificates
|
||||||
|
if err := registration.SaveCerts(cfg.CertDir, certs, k); err != nil {
|
||||||
|
lgr.Error("Failed to save certificates", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
lgr.Info("Certificates saved", "cert_dir", cfg.CertDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := mtls.LoadMTLSCredentialsFromFiles(
|
||||||
|
cfg.CertDir+"/ca.crt",
|
||||||
|
cfg.CertDir+"/client.crt",
|
||||||
|
cfg.CertDir+"/client.key",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to load TLS credentials", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize log buffer for offline storage
|
||||||
|
dbPath := getEnvOrDefault("BUFFER_DB", "/var/lib/hellreign-agent/agent_buffer.db")
|
||||||
|
logBuf, err := buffer.NewLogBuffer(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to create log buffer", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer func() { _ = logBuf.Close() }()
|
||||||
|
lgr.Info("Log buffer initialized", "path", dbPath)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wg := &errgroup.Group{}
|
||||||
|
|
||||||
|
grpcAddr := cfg.GRPCURL
|
||||||
|
if grpcAddr == "" {
|
||||||
|
grpcAddr = cfg.BackendURL
|
||||||
|
}
|
||||||
|
grpcAddr = strings.TrimPrefix(grpcAddr, "http://")
|
||||||
|
grpcAddr = strings.TrimPrefix(grpcAddr, "https://")
|
||||||
|
// Start command executor
|
||||||
|
wg.Go(func() error {
|
||||||
|
cmdexe := new(commander.CommandExecutor)
|
||||||
|
ccli := client.New(cmdexe, cfg.Label, cfg.Label)
|
||||||
|
return ccli.HandleCommands(ctx, grpcAddr, creds)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start services update stream
|
||||||
|
if len(cfg.Services) > 0 {
|
||||||
|
wg.Go(func() error {
|
||||||
|
return reportServices(ctx, grpcAddr, creds, cfg.Label, cfg.Services, lgr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start system metrics reporting
|
||||||
|
wg.Go(func() error {
|
||||||
|
return reportSystemMetrics(ctx, grpcAddr, creds, cfg.Label, lgr)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start log collectors
|
||||||
|
if len(cfg.Services) > 0 {
|
||||||
|
wg.Go(func() error {
|
||||||
|
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to gRPC: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
ccli := proto.NewCollectorClient(conn)
|
||||||
|
|
||||||
|
svcWg := new(errgroup.Group)
|
||||||
|
for _, svc := range cfg.Services {
|
||||||
|
svc := svc
|
||||||
|
var src logsource.LogSource
|
||||||
|
switch svc.Type {
|
||||||
|
case "journald":
|
||||||
|
src, err = journald.New(svc, os.Getenv("JOURNALD_LOGDIR"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create journald source %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
if svc.Path == nil {
|
||||||
|
return fmt.Errorf("path is required for file log source %q", svc.Name)
|
||||||
|
}
|
||||||
|
src, err = file.New(*svc.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
case "docker":
|
||||||
|
src, err = docker.New(svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create docker source for container %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
case "kubernetes":
|
||||||
|
src, err = kubernetes.New(svc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create kubernetes source for pod %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcWg.Go(func() error {
|
||||||
|
lgr.Info("Starting log stream", "service", svc.Name)
|
||||||
|
|
||||||
|
// First, flush any buffered logs from offline period
|
||||||
|
if err := flushBufferedLogs(ctx, ccli, logBuf, svc.Name, cfg.Label, cfg.RegistrationToken, lgr); err != nil {
|
||||||
|
lgr.Error("Failed to flush buffered logs", "service", svc.Name, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scli, err := ccli.Stream(
|
||||||
|
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||||
|
"whoami": []string{cfg.Label},
|
||||||
|
"service": []string{svc.Name},
|
||||||
|
"token": []string{cfg.RegistrationToken},
|
||||||
|
"services": lo.Map(cfg.Services, func(item config.ServiceConfig, _ int) string {
|
||||||
|
return item.Name
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := src.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("ReadLine error", "service", svc.Name, "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scli.Send(&proto.CollectorRequest{
|
||||||
|
Message: line,
|
||||||
|
}); err != nil {
|
||||||
|
// Connection failed, buffer the log
|
||||||
|
lgr.Warn("Send failed, buffering log", "service", svc.Name, "err", err)
|
||||||
|
if storeErr := logBuf.Store(svc.Name, line); storeErr != nil {
|
||||||
|
lgr.Error("Failed to buffer log", "service", svc.Name, "err", storeErr)
|
||||||
|
}
|
||||||
|
// Try to reconnect
|
||||||
|
if reconnectErr := reconnectStream(ctx, &scli, ccli, svc.Name, cfg.Label, cfg.RegistrationToken, logBuf, lgr); reconnectErr != nil {
|
||||||
|
return reconnectErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return svcWg.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wg.Wait(); err != nil {
|
||||||
|
lgr.Error("Agent dead", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushBufferedLogs sends any buffered logs to the server
|
||||||
|
func flushBufferedLogs(
|
||||||
|
ctx context.Context,
|
||||||
|
ccli proto.CollectorClient,
|
||||||
|
logBuf *buffer.LogBuffer,
|
||||||
|
service, agentName, token string,
|
||||||
|
lgr *logger.Logger,
|
||||||
|
) error {
|
||||||
|
count, err := logBuf.Count()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lgr.Info("Flushing buffered logs", "service", service, "count", count)
|
||||||
|
|
||||||
|
scli, err := ccli.Stream(
|
||||||
|
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||||
|
"whoami": []string{agentName},
|
||||||
|
"service": []string{service},
|
||||||
|
"token": []string{token},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stream for flush: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 100
|
||||||
|
var deletedIDs []int64
|
||||||
|
|
||||||
|
for {
|
||||||
|
logs, err := logBuf.GetPending(batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, logEntry := range logs {
|
||||||
|
if err := scli.Send(&proto.CollectorRequest{Message: logEntry.Message}); err != nil {
|
||||||
|
lgr.Error("Failed to send buffered log", "service", service, "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deletedIDs = append(deletedIDs, logEntry.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete successfully sent logs
|
||||||
|
if err := logBuf.DeleteBatch(deletedIDs); err != nil {
|
||||||
|
lgr.Error("Failed to delete sent logs from buffer", "service", service, "err", err)
|
||||||
|
}
|
||||||
|
deletedIDs = deletedIDs[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = scli.CloseAndRecv()
|
||||||
|
lgr.Info("Buffer flush complete", "service", service)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnectStream attempts to recreate a gRPC stream connection
|
||||||
|
func reconnectStream(
|
||||||
|
ctx context.Context,
|
||||||
|
scli *grpc.ClientStreamingClient[proto.CollectorRequest, proto.CollectorResponse],
|
||||||
|
ccli proto.CollectorClient,
|
||||||
|
service, agentName, token string,
|
||||||
|
buf *buffer.LogBuffer,
|
||||||
|
lgr *logger.Logger,
|
||||||
|
) error {
|
||||||
|
lgr.Info("Attempting to reconnect stream...", "service", service)
|
||||||
|
|
||||||
|
// Try up to 5 times with exponential backoff
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
time.Sleep(time.Duration(i+1) * time.Second)
|
||||||
|
|
||||||
|
newCli, err := ccli.Stream(
|
||||||
|
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||||
|
"whoami": []string{agentName},
|
||||||
|
"service": []string{service},
|
||||||
|
"token": []string{token},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Warn("Reconnect attempt failed", "service", service, "attempt", i+1, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
*scli = newCli
|
||||||
|
lgr.Info("Stream reconnected successfully", "service", service)
|
||||||
|
return flushBufferedLogs(ctx, ccli, buf, service, agentName, token, lgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to reconnect after 5 attempts for service %s", service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportServices periodically sends service status updates to the backend via gRPC.
|
||||||
|
func reportServices(
|
||||||
|
ctx context.Context,
|
||||||
|
grpcAddr string,
|
||||||
|
creds credentials.TransportCredentials,
|
||||||
|
label string,
|
||||||
|
services []config.ServiceConfig,
|
||||||
|
lgr *logger.Logger,
|
||||||
|
) error {
|
||||||
|
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect for services report: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
ccli := proto.NewCollectorClient(conn)
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Send immediately on start, then every 5 seconds
|
||||||
|
for {
|
||||||
|
svcUpdates := make([]*proto.ServicesUpdate_ServiceUpdate, 0, len(services))
|
||||||
|
for _, svc := range services {
|
||||||
|
status := checkServiceStatus(svc, lgr)
|
||||||
|
svcUpdates = append(svcUpdates, &proto.ServicesUpdate_ServiceUpdate{
|
||||||
|
Name: svc.Name,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
md := metadata.New(map[string]string{"whoami": label})
|
||||||
|
_, err := ccli.ReportServices(
|
||||||
|
metadata.NewOutgoingContext(ctx, md),
|
||||||
|
&proto.ServicesUpdate{Services: svcUpdates},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Warn("Failed to report services", "err", err)
|
||||||
|
} else {
|
||||||
|
lgr.Debug("Services reported successfully", "count", len(services))
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkServiceStatus checks if a service is alive based on its type.
|
||||||
|
func checkServiceStatus(svc config.ServiceConfig, lgr *logger.Logger) string {
|
||||||
|
// If systemd_unit is specified, check systemd first
|
||||||
|
if svc.SystemdUnit != nil && *svc.SystemdUnit != "" {
|
||||||
|
status := checkSystemdService(*svc.SystemdUnit)
|
||||||
|
if status != "up" {
|
||||||
|
lgr.Debug("Systemd service check", "unit", *svc.SystemdUnit, "status", status)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For docker type, check container is running
|
||||||
|
if svc.Type == "docker" {
|
||||||
|
status := checkDockerContainer(svc.Name)
|
||||||
|
if status != "up" {
|
||||||
|
lgr.Debug("Docker container check", "container", svc.Name, "status", status)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "up"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSystemdService checks if a systemd service is active.
|
||||||
|
func checkSystemdService(unit string) string {
|
||||||
|
cmd := exec.Command("systemctl", "is-active", "--quiet", unit)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
return "up"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDockerContainer checks if a Docker container is running.
|
||||||
|
func checkDockerContainer(name string) string {
|
||||||
|
cmd := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", name)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(out)) == "true" {
|
||||||
|
return "up"
|
||||||
|
}
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportSystemMetrics periodically collects and sends system metrics to the backend via gRPC.
|
||||||
|
func reportSystemMetrics(
|
||||||
|
ctx context.Context,
|
||||||
|
grpcAddr string,
|
||||||
|
creds credentials.TransportCredentials,
|
||||||
|
label string,
|
||||||
|
lgr *logger.Logger,
|
||||||
|
) error {
|
||||||
|
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect for metrics report: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
ccli := proto.NewCollectorClient(conn)
|
||||||
|
collector := agentmetrics.NewCollector()
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
lgr.Info("System metrics collector started")
|
||||||
|
|
||||||
|
for {
|
||||||
|
metrics, err := collector.Collect()
|
||||||
|
if err != nil {
|
||||||
|
lgr.Warn("Failed to collect system metrics", "err", err)
|
||||||
|
} else {
|
||||||
|
md := metadata.New(map[string]string{"whoami": label})
|
||||||
|
_, err := ccli.ReportSystemMetrics(
|
||||||
|
metadata.NewOutgoingContext(ctx, md),
|
||||||
|
&proto.SystemMetrics{
|
||||||
|
CpuPercent: metrics.CPUPercent,
|
||||||
|
MemoryPercent: metrics.MemoryPercent,
|
||||||
|
DiskPercent: metrics.DiskPercent,
|
||||||
|
NetworkRxBytes: metrics.NetworkRxBytes,
|
||||||
|
NetworkTxBytes: metrics.NetworkTxBytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Warn("Failed to report system metrics", "err", err)
|
||||||
|
} else {
|
||||||
|
lgr.Debug("System metrics reported",
|
||||||
|
"cpu", metrics.CPUPercent,
|
||||||
|
"mem", metrics.MemoryPercent,
|
||||||
|
"disk", metrics.DiskPercent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+272
-20
@@ -2,17 +2,28 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/docs"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/docs"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/config"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
swaggerFiles "github.com/swaggo/files"
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @securityDefinitions.apikey Bearer
|
// @securityDefinitions.apikey Bearer
|
||||||
@@ -37,8 +48,74 @@ func main() {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
h := handlers.New(db)
|
h := handlers.New(db)
|
||||||
agents := handlers.AgentsGroup{Handlers: h}
|
|
||||||
|
// Initialize registration tokens table
|
||||||
|
if err := h.Repo.InitRegistrationTokens(); err != nil {
|
||||||
|
log.Printf("Warning: failed to initialize registration tokens table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize jobs table
|
||||||
|
jobRepo := repository.NewJobRepository(db)
|
||||||
|
if err := jobRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Printf("Warning: failed to initialize jobs table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize ClickHouse and log repository
|
||||||
|
logRepo := repository.NewLogRepository()
|
||||||
|
if cfg.Database.Clickhouse_host != "" {
|
||||||
|
go func() {
|
||||||
|
db, err := storage.OpenClickHouseWithRetry(storage.ClickHouseConfig{
|
||||||
|
Host: cfg.Database.Clickhouse_host,
|
||||||
|
User: cfg.Database.Clickhouse_user,
|
||||||
|
Password: cfg.Database.Clickhouse_password,
|
||||||
|
Database: cfg.Database.Clickhouse_database,
|
||||||
|
}, 10, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: ClickHouse connection failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("ClickHouse connected successfully")
|
||||||
|
logRepo.SetDB(db)
|
||||||
|
if err := logRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Printf("Warning: Failed to initialize logs table: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Collector (log streaming) with its own ConnTracker
|
||||||
|
collTracker := collector.NewConnTracker()
|
||||||
|
coll := collector.New(logRepo, collTracker)
|
||||||
|
|
||||||
|
// Initialize ConnTracker for Commander agent lifecycle
|
||||||
|
cmdTracker := commander.NewConnTracker()
|
||||||
|
cmdr := commander.New(jobRepo, cmdTracker)
|
||||||
|
|
||||||
|
// Initialize script interpreter repository and service
|
||||||
|
scriptRepo := repository.NewScriptInterpreterRepo(db)
|
||||||
|
if err := scriptRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Fatalf("Warning: failed to initialize script interpreters table: %v\n", err)
|
||||||
|
}
|
||||||
|
scriptSvc := service.NewScriptServiceWithInterpreters(h.Repo, scriptRepo)
|
||||||
|
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdTracker,
|
||||||
|
os.Getenv("WHEREAMI"))
|
||||||
|
jobsHandlers := handlers.NewJobsHandlers(cmdTracker, scriptSvc,
|
||||||
|
os.Getenv("WHEREAMI"), /* our address for redirects */
|
||||||
|
jobRepo,
|
||||||
|
)
|
||||||
|
|
||||||
|
scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptSvc, cmdr,
|
||||||
|
os.Getenv("WHEREAMI"))
|
||||||
|
|
||||||
|
graphPath := os.Getenv("GRAPH_YAML_PATH")
|
||||||
|
if graphPath == "" {
|
||||||
|
graphPath = "/etc/hellreign/services.yaml"
|
||||||
|
}
|
||||||
|
graphHandlers := handlers.NewGraphHandlers(graphPath, coll)
|
||||||
|
|
||||||
|
agents := handlers.NewAgentsGroup(h, coll)
|
||||||
auth := handlers.AuthGroup{Handlers: h}
|
auth := handlers.AuthGroup{Handlers: h}
|
||||||
|
agentReg := handlers.NewAgentRegistrationGroup(h)
|
||||||
|
agentDeploy := handlers.NewAgentDeployGroup(h)
|
||||||
|
|
||||||
// Create admin user from config if not exists
|
// Create admin user from config if not exists
|
||||||
if cfg.Admin.Admin_login != "" && cfg.Admin.Admin_password != "" {
|
if cfg.Admin.Admin_login != "" && cfg.Admin.Admin_password != "" {
|
||||||
@@ -49,17 +126,27 @@ func main() {
|
|||||||
Login: cfg.Admin.Admin_login,
|
Login: cfg.Admin.Admin_login,
|
||||||
Password: cfg.Admin.Admin_password,
|
Password: cfg.Admin.Admin_password,
|
||||||
PermissionView: true,
|
PermissionView: true,
|
||||||
|
PermissionManage: true,
|
||||||
PermissionAdmin: true,
|
PermissionAdmin: true,
|
||||||
|
IsActive: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: failed to create admin user: %v", err)
|
log.Printf("Warning: failed to create admin user: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Println("Admin user created from config")
|
log.Println("Admin user created from config")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure existing admin is activated
|
||||||
|
if err := h.Repo.ActivateUserByLogin(cfg.Admin.Admin_login); err != nil {
|
||||||
|
log.Printf("Warning: failed to activate admin user: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Admin user activated")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
router.Use(handlers.CorsMiddleware("http://127.0.0.1:5173;http://localhost:5173"))
|
||||||
docs.SwaggerInfo.BasePath = "/api/v1"
|
docs.SwaggerInfo.BasePath = "/api/v1"
|
||||||
docs.SwaggerInfo.Title = "HellreigN"
|
docs.SwaggerInfo.Title = "HellreigN"
|
||||||
docs.SwaggerInfo.Version = "1.0"
|
docs.SwaggerInfo.Version = "1.0"
|
||||||
@@ -73,17 +160,45 @@ func main() {
|
|||||||
authGroup := v1.Group("/auth")
|
authGroup := v1.Group("/auth")
|
||||||
{
|
{
|
||||||
authGroup.POST("/login", auth.Login)
|
authGroup.POST("/login", auth.Login)
|
||||||
|
authGroup.POST("/register", auth.RegisterUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth token management (requires auth)
|
// Auth token management (requires auth)
|
||||||
authTokenGroup := v1.Group("/auth")
|
authTokenGroup := v1.Group("/auth")
|
||||||
authTokenGroup.Use(auth.AuthMiddleware())
|
authTokenGroup.Use(auth.AuthMiddleware())
|
||||||
{
|
{
|
||||||
authTokenGroup.POST("/token", handlers.RequireAdmin(), auth.CreateToken)
|
authTokenGroup.POST("/token", auth.CreateToken)
|
||||||
authTokenGroup.GET("/validate", auth.ValidateToken)
|
authTokenGroup.GET("/validate", auth.ValidateToken)
|
||||||
authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens)
|
authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens)
|
||||||
authTokenGroup.DELETE("/token", auth.DeleteMyToken)
|
authTokenGroup.DELETE("/token", auth.DeleteMyToken)
|
||||||
authTokenGroup.DELETE("/tokens/:login", handlers.RequireAdmin(), auth.DeleteToken)
|
authTokenGroup.DELETE("/tokens/:login", handlers.RequireAdmin(), auth.DeleteToken)
|
||||||
|
|
||||||
|
// User management (admin only) - Full CRUD
|
||||||
|
authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser)
|
||||||
|
authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser)
|
||||||
|
authTokenGroup.PUT(
|
||||||
|
"/users/:login/permissions",
|
||||||
|
handlers.RequireAdmin(),
|
||||||
|
auth.UpdateUserPermissions,
|
||||||
|
)
|
||||||
|
authTokenGroup.PUT(
|
||||||
|
"/users/:login/password",
|
||||||
|
handlers.RequireAdmin(),
|
||||||
|
auth.ResetUserPassword,
|
||||||
|
)
|
||||||
|
|
||||||
|
// User activation management (admin only)
|
||||||
|
authTokenGroup.POST(
|
||||||
|
"/users/:login/activate",
|
||||||
|
handlers.RequireAdmin(),
|
||||||
|
auth.ActivateUser,
|
||||||
|
)
|
||||||
|
authTokenGroup.POST(
|
||||||
|
"/users/:login/deactivate",
|
||||||
|
handlers.RequireAdmin(),
|
||||||
|
auth.DeactivateUser,
|
||||||
|
)
|
||||||
|
authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agents (requires manage_agent permission)
|
// Agents (requires manage_agent permission)
|
||||||
@@ -91,29 +206,60 @@ func main() {
|
|||||||
agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||||
{
|
{
|
||||||
agentsGroup.GET("", agents.List)
|
agentsGroup.GET("", agents.List)
|
||||||
|
agentsGroup.GET("/system-metrics", agents.GetSystemMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs (requires admin permission)
|
||||||
|
jobsGroup := v1.Group("/jobs")
|
||||||
|
jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
||||||
|
{
|
||||||
|
jobsGroup.POST("", jobsHandlers.AddJob)
|
||||||
|
jobsGroup.POST("/:id/wait", jobsHandlers.WaitJob)
|
||||||
|
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service dependency graph
|
||||||
|
graphGroup := v1.Group("/graph")
|
||||||
|
graphGroup.Use(auth.AuthMiddleware())
|
||||||
|
{
|
||||||
|
// Read-only endpoints: GET (require view)
|
||||||
|
graphView := graphGroup.Group("")
|
||||||
|
graphView.Use(handlers.RequireView())
|
||||||
|
{
|
||||||
|
graphView.GET("", graphHandlers.GetGraph)
|
||||||
|
graphView.GET("/order", graphHandlers.StartupOrder)
|
||||||
|
graphView.GET("/cycle", graphHandlers.CycleCheck)
|
||||||
|
graphView.GET("/failure", graphHandlers.GetFailureRootCause)
|
||||||
|
}
|
||||||
|
// Write endpoints: PUT (require admin)
|
||||||
|
graphAdmin := graphGroup.Group("")
|
||||||
|
graphAdmin.Use(handlers.RequireAdmin())
|
||||||
|
{
|
||||||
|
graphAdmin.PUT("", graphHandlers.UpdateYAML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent registration
|
||||||
|
agentRegGroup := v1.Group("/agents")
|
||||||
|
{
|
||||||
|
agentRegGroup.POST("/register", agentReg.Register)
|
||||||
|
}
|
||||||
|
agentRegTokenGroup := v1.Group("/agents")
|
||||||
|
agentRegTokenGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||||
|
{
|
||||||
|
agentRegTokenGroup.POST("/register-token", agentReg.CreateRegistrationToken)
|
||||||
|
agentRegTokenGroup.POST("/deploy", agentDeploy.DeployAgents)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs (requires view permission)
|
// Logs (requires view permission)
|
||||||
logsGroup := v1.Group("/logs")
|
logsGroup := v1.Group("/logs")
|
||||||
logsGroup.Use(auth.AuthMiddleware(), handlers.RequireView())
|
logsGroup.Use(auth.AuthMiddleware(), handlers.RequireView())
|
||||||
{
|
{
|
||||||
if cfg.Database.Clickhouse_host != "" {
|
// Mock logs endpoint (always available, no ClickHouse required)
|
||||||
chConn, err := storage.OpenClickHouse(storage.ClickHouseConfig{
|
mockLogHandlers := handlers.NewLogHandlers(nil)
|
||||||
Host: cfg.Database.Clickhouse_host,
|
logsGroup.GET("/mock", mockLogHandlers.GetMockLogs)
|
||||||
User: cfg.Database.Clickhouse_user,
|
|
||||||
Password: cfg.Database.Clickhouse_password,
|
|
||||||
Database: cfg.Database.Clickhouse_database,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: ClickHouse connection failed: %v", err)
|
|
||||||
} else {
|
|
||||||
defer chConn.Close()
|
|
||||||
|
|
||||||
logRepo := repository.NewLogRepository(chConn)
|
|
||||||
if err := logRepo.Init(context.Background()); err != nil {
|
|
||||||
log.Printf("Warning: Failed to initialize logs table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ClickHouse log handlers (always registered, work when ClickHouse connects)
|
||||||
logHandlers := handlers.NewLogHandlers(logRepo)
|
logHandlers := handlers.NewLogHandlers(logRepo)
|
||||||
logsGroup.POST("", logHandlers.Insert)
|
logsGroup.POST("", logHandlers.Insert)
|
||||||
logsGroup.POST("/batch", logHandlers.InsertBatch)
|
logsGroup.POST("/batch", logHandlers.InsertBatch)
|
||||||
@@ -122,9 +268,115 @@ func main() {
|
|||||||
logsGroup.GET("/agents", logHandlers.GetAgents)
|
logsGroup.GET("/agents", logHandlers.GetAgents)
|
||||||
logsGroup.GET("/levels", logHandlers.GetLevels)
|
logsGroup.GET("/levels", logHandlers.GetLevels)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Scripts (requires admin permission)
|
||||||
|
scriptsGroup := v1.Group("/scripts")
|
||||||
|
scriptsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
||||||
|
{
|
||||||
|
scriptsGroup.POST("/run", scriptHandlers.RunScript)
|
||||||
|
scriptsGroup.GET("/interpreters", scriptHandlers.ListInterpreters)
|
||||||
|
scriptsGroup.POST("/interpreters", scriptHandlers.CreateInterpreter)
|
||||||
|
scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter)
|
||||||
|
scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter)
|
||||||
|
scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter)
|
||||||
|
|
||||||
|
// Script management (tree, CRUD)
|
||||||
|
scriptsGroup.GET("/tree", scriptManageHandlers.GetTree)
|
||||||
|
scriptsGroup.POST("", scriptManageHandlers.CreateScript)
|
||||||
|
scriptsGroup.GET("/:id", scriptManageHandlers.GetScript)
|
||||||
|
scriptsGroup.PUT("/:id", scriptManageHandlers.UpdateScript)
|
||||||
|
scriptsGroup.DELETE("/:id", scriptManageHandlers.DeleteScript)
|
||||||
|
scriptsGroup.POST("/:id/run", scriptManageHandlers.RunScriptByID)
|
||||||
|
|
||||||
|
// Folder management
|
||||||
|
scriptsGroup.POST("/folder", scriptManageHandlers.CreateFolder)
|
||||||
|
scriptsGroup.DELETE("/folder", scriptManageHandlers.DeleteFolder)
|
||||||
|
|
||||||
|
// Rename script or folder
|
||||||
|
scriptsGroup.POST("/rename", scriptManageHandlers.Rename)
|
||||||
|
|
||||||
|
// Get script by path
|
||||||
|
scriptsGroup.GET("/by-path", scriptManageHandlers.GetScriptByPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Fatal(router.Run(":8080"))
|
// Start gRPC server with mTLS in background
|
||||||
|
grpcPort := os.Getenv("GRPC_PORT")
|
||||||
|
if grpcPort == "" {
|
||||||
|
grpcPort = "9001"
|
||||||
|
}
|
||||||
|
|
||||||
|
certDir := os.Getenv("SSL_CERT_DIR")
|
||||||
|
if certDir == "" {
|
||||||
|
certDir = "/var/lib/hellreign/ssl"
|
||||||
|
}
|
||||||
|
|
||||||
|
certFile := certDir + "/server.crt"
|
||||||
|
keyFile := certDir + "/server.key"
|
||||||
|
caFile := certDir + "/ca.crt"
|
||||||
|
|
||||||
|
// Load server cert
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load server cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load CA cert for client verification
|
||||||
|
caCert, err := os.ReadFile(caFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load CA cert: %v", err)
|
||||||
|
}
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
ClientCAs: caCertPool,
|
||||||
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer(
|
||||||
|
grpc.Creds(credentials.NewTLS(tlsConfig)),
|
||||||
|
grpc.StatsHandler(collTracker),
|
||||||
|
grpc.StatsHandler(cmdTracker),
|
||||||
|
)
|
||||||
|
proto.RegisterCommanderServer(grpcServer, cmdr)
|
||||||
|
proto.RegisterCollectorServer(grpcServer, coll)
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", ":"+grpcPort)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to listen on gRPC port %s: %v", grpcPort, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, ctx := errgroup.WithContext(context.Background())
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
log.Printf("gRPC server starting on port %s with mTLS", grpcPort)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- grpcServer.Serve(lis) }()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
grpcServer.GracefulStop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
log.Printf("HTTP server starting on :8080")
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- router.Run(":8080") }()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -2,7 +2,9 @@ FROM golang:1.26.1 as builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY backend/ backend/
|
||||||
|
COPY proto/ proto/
|
||||||
|
WORKDIR /app/backend
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
ENV GIN_MODE=release
|
ENV GIN_MODE=release
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
@@ -12,13 +14,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
|
|
||||||
FROM alpine:3.23.0
|
FROM alpine:3.23.0
|
||||||
|
|
||||||
RUN apk add --no-cache curl openssl bash
|
RUN apk add --no-cache curl openssl bash ansible sqlite
|
||||||
|
|
||||||
COPY --from=builder /app/backend .
|
COPY --from=builder /app/backend/backend .
|
||||||
#COPY --from=builder /app/scripts /etc/mnemosyne/scripts
|
COPY --from=builder /app/backend/scripts /etc/hellreign/scripts
|
||||||
#RUN chmod +x /etc/mnemosyne/scripts/generate-certs.sh
|
RUN chmod +x /etc/hellreign/scripts/generate-certs.sh
|
||||||
|
|
||||||
EXPOSE 8080
|
# Generate certificates on container start
|
||||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "curl --fail http://localhost:8080/health" ]
|
ENTRYPOINT ["/bin/sh", "-c", "/etc/hellreign/scripts/generate-certs.sh ${SSL_CERT_DIR:-/var/lib/hellreign/ssl} && exec ./backend"]
|
||||||
|
|
||||||
CMD ["./backend"]
|
|
||||||
|
|||||||
+2468
-5
File diff suppressed because it is too large
Load Diff
+2468
-5
File diff suppressed because it is too large
Load Diff
+1588
-1
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -3,11 +3,16 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260404174628-3389df740c20
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/samber/lo v1.53.0
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.1
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
|
golang.org/x/crypto v0.49.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.48.1
|
modernc.org/sqlite v1.48.1
|
||||||
)
|
)
|
||||||
@@ -65,15 +70,16 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.25.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
golang.org/x/crypto v0.49.0 // indirect
|
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
|||||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||||
@@ -74,6 +78,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
@@ -132,6 +138,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
@@ -171,8 +179,16 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
|||||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
@@ -243,6 +259,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
|||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUnknownDeployType is returned when an unsupported deployment type is specified
|
||||||
|
var ErrUnknownDeployType = fmt.Errorf("unknown deploy type, expected 'docker' or 'binary'")
|
||||||
|
|
||||||
|
// Executor handles running Ansible playbooks
|
||||||
|
type Executor struct {
|
||||||
|
workDir string
|
||||||
|
grpcServerHost string
|
||||||
|
grpcServerPort string
|
||||||
|
backendURL string
|
||||||
|
giteaReleasesURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutorConfig holds configuration for the Executor
|
||||||
|
type ExecutorConfig struct {
|
||||||
|
WorkDir string
|
||||||
|
GRPCServerHost string
|
||||||
|
GRPCServerPort string
|
||||||
|
BackendURL string
|
||||||
|
GiteaReleasesURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new Ansible executor
|
||||||
|
func NewExecutor(cfg ExecutorConfig) *Executor {
|
||||||
|
return &Executor{
|
||||||
|
workDir: cfg.WorkDir,
|
||||||
|
grpcServerHost: cfg.GRPCServerHost,
|
||||||
|
grpcServerPort: cfg.GRPCServerPort,
|
||||||
|
backendURL: cfg.BackendURL,
|
||||||
|
giteaReleasesURL: cfg.GiteaReleasesURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResult holds the result of a deployment
|
||||||
|
type DeployResult struct {
|
||||||
|
Host string
|
||||||
|
Success bool
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkDir returns the work directory path
|
||||||
|
func (e *Executor) WorkDir() string {
|
||||||
|
return e.workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// GRPCURL returns the gRPC server URL (host:port)
|
||||||
|
func (e *Executor) GRPCURL() string {
|
||||||
|
return e.grpcServerHost + ":" + e.grpcServerPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDockerCollection verifies that the community.docker Ansible collection is installed.
|
||||||
|
// Returns an error if the collection is not found.
|
||||||
|
func (e *Executor) CheckDockerCollection() error {
|
||||||
|
cmd := exec.Command("ansible-galaxy", "collection", "list", "community.docker")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("community.docker collection not found: %s", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ansible-galaxy collection list returns output like:
|
||||||
|
// # /usr/share/ansible/collections/ansible_collections
|
||||||
|
// Collection Version
|
||||||
|
// ---------------- -------
|
||||||
|
// community.docker 3.10.0
|
||||||
|
//
|
||||||
|
// If the collection is not installed, it won't appear in the output.
|
||||||
|
if !strings.Contains(stdout.String(), "community.docker") {
|
||||||
|
return fmt.Errorf("community.docker collection is not installed. Run: ansible-galaxy collection install community.docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy runs Ansible playbook for the given inventory
|
||||||
|
func (e *Executor) Deploy(
|
||||||
|
ctx context.Context,
|
||||||
|
inventoryPath string,
|
||||||
|
deployType string,
|
||||||
|
) ([]DeployResult, error) {
|
||||||
|
if deployType != "docker" && deployType != "binary" {
|
||||||
|
return nil, fmt.Errorf("invalid deploy type %q: %w", deployType, ErrUnknownDeployType)
|
||||||
|
}
|
||||||
|
|
||||||
|
playbookName := "binary_deploy.yml"
|
||||||
|
if deployType == "docker" {
|
||||||
|
playbookName = "docker_deploy.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
playbookPath := filepath.Join(e.workDir, playbookName)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "ansible-playbook",
|
||||||
|
"-i", inventoryPath,
|
||||||
|
"-e", fmt.Sprintf("backend_url=%s", e.backendURL),
|
||||||
|
"-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort),
|
||||||
|
"-e", fmt.Sprintf("gitea_releases_url=%s", e.giteaReleasesURL),
|
||||||
|
playbookPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
runErr := cmd.Run()
|
||||||
|
|
||||||
|
// Parse results per host (simplified - returns single result for all)
|
||||||
|
return []DeployResult{
|
||||||
|
{
|
||||||
|
Host: "all",
|
||||||
|
Success: runErr == nil,
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
Err: runErr,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployParallel runs Ansible playbook for multiple inventories in parallel
|
||||||
|
func (e *Executor) DeployParallel(
|
||||||
|
ctx context.Context,
|
||||||
|
inventoryPaths []string,
|
||||||
|
deployType string,
|
||||||
|
) (map[string][]DeployResult, error) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
results := make(map[string][]DeployResult)
|
||||||
|
errCh := make(chan error, len(inventoryPaths))
|
||||||
|
|
||||||
|
for _, path := range inventoryPaths {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p string) {
|
||||||
|
defer wg.Done()
|
||||||
|
res, err := e.Deploy(ctx, p, deployType)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
results[p] = res
|
||||||
|
mu.Unlock()
|
||||||
|
}(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
// Collect errors
|
||||||
|
var errs []error
|
||||||
|
for err := range errCh {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return results, fmt.Errorf("some deployments failed: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePlaybook writes a playbook to the work directory
|
||||||
|
func (e *Executor) WritePlaybook(name string, content string) error {
|
||||||
|
path := filepath.Join(e.workDir, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAllPlaybooks writes all playbooks to the work directory
|
||||||
|
func (e *Executor) WriteAllPlaybooks() error {
|
||||||
|
if err := e.WritePlaybook("binary_deploy.yml", BinaryDeployPlaybook); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.WritePlaybook("docker_deploy.yml", DockerDeployPlaybook)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InventoryHost represents a single host in the inventory
|
||||||
|
type InventoryHost struct {
|
||||||
|
Name string
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
AuthMethod string
|
||||||
|
SSHKey string
|
||||||
|
Password string
|
||||||
|
DeployType string
|
||||||
|
Token string
|
||||||
|
GRPCURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory represents an Ansible inventory file
|
||||||
|
type Inventory struct {
|
||||||
|
Hosts []InventoryHost
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryTemplateText = `{{- range $i, $host := .Hosts }}
|
||||||
|
{{ $host.Name }} ansible_host={{ $host.IP }} ansible_port={{ $host.Port }} ansible_user={{ $host.User }} ansible_connection=ssh{{ if eq $host.AuthMethod "key" }} ansible_ssh_private_key_file={{ $host.SSHKey }}{{ end }}{{ if eq $host.AuthMethod "password" }} ansible_ssh_pass={{ $host.Password }}{{ end }}
|
||||||
|
deploy_type={{ $host.DeployType }}
|
||||||
|
agent_token={{ $host.Token }}
|
||||||
|
agent_label={{ $host.Name }}
|
||||||
|
grpc_url={{ $host.GRPCURL }}
|
||||||
|
{{ end -}}`
|
||||||
|
|
||||||
|
// GenerateInventory generates an Ansible inventory file from the given hosts
|
||||||
|
func GenerateInventory(hosts []InventoryHost, outputPath string) error {
|
||||||
|
tmpl, err := template.New("inventory").Parse(inventoryTemplateText)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse inventory template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
dir := filepath.Dir(outputPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create inventory directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create inventory file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := tmpl.Execute(file, Inventory{Hosts: hosts}); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute inventory template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
// BinaryDeployPlaybook returns the Ansible playbook for binary deployment.
|
||||||
|
// Downloads the agent binary, writes config, and installs a systemd unit for automatic restart.
|
||||||
|
const BinaryDeployPlaybook = `---
|
||||||
|
- name: Deploy HellreigN Agent (Binary)
|
||||||
|
hosts: all
|
||||||
|
become: yes
|
||||||
|
vars:
|
||||||
|
agent_label: "{{ agent_label }}"
|
||||||
|
agent_token: "{{ agent_token }}"
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
install_dir: /opt/hellreign
|
||||||
|
bin_name: hellreign-agent
|
||||||
|
cert_dir: "{{ install_dir }}/certs"
|
||||||
|
gitea_releases_url: "{{ gitea_releases_url | default('https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download') }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Create installation directory
|
||||||
|
file:
|
||||||
|
path: "{{ install_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create certificates directory
|
||||||
|
file:
|
||||||
|
path: "{{ cert_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Download HellreigN Agent binary
|
||||||
|
get_url:
|
||||||
|
url: "{{ gitea_releases_url }}/{{ bin_name }}"
|
||||||
|
dest: "{{ install_dir }}/{{ bin_name }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create agent configuration
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
|
||||||
|
label: "{{ agent_label }}"
|
||||||
|
registration_token: "{{ agent_token }}"
|
||||||
|
cert_dir: "{{ cert_dir }}"
|
||||||
|
services:
|
||||||
|
- name: system
|
||||||
|
type: journald
|
||||||
|
dest: "{{ install_dir }}/config.yml"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Create systemd unit file
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=HellreigN Agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart={{ install_dir }}/{{ bin_name }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=CONFIG_FILE={{ install_dir }}/config.yml
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
dest: /etc/systemd/system/hellreign-agent.service
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Reload systemd daemon
|
||||||
|
systemd:
|
||||||
|
daemon_reload: yes
|
||||||
|
|
||||||
|
- name: Enable and start HellreigN Agent service
|
||||||
|
systemd:
|
||||||
|
name: hellreign-agent
|
||||||
|
enabled: yes
|
||||||
|
state: started
|
||||||
|
|
||||||
|
- name: Wait for agent to start
|
||||||
|
pause:
|
||||||
|
seconds: 3
|
||||||
|
|
||||||
|
- name: Verify HellreigN Agent is running
|
||||||
|
command: systemctl is-active --quiet hellreign-agent
|
||||||
|
changed_when: false
|
||||||
|
`
|
||||||
|
|
||||||
|
// DockerDeployPlaybook returns the Ansible playbook for Docker deployment.
|
||||||
|
const DockerDeployPlaybook = `---
|
||||||
|
- name: Deploy HellreigN Agent (Docker)
|
||||||
|
hosts: all
|
||||||
|
become: yes
|
||||||
|
vars:
|
||||||
|
agent_label: "{{ agent_label }}"
|
||||||
|
agent_token: "{{ agent_token }}"
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
|
||||||
|
container_name: hellreign-agent-{{ agent_label }}
|
||||||
|
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
|
||||||
|
install_dir: /opt/hellreign
|
||||||
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
config_dir: /etc/hellreign-agent
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Install Docker (if not present)
|
||||||
|
block:
|
||||||
|
- name: Check if Docker is installed
|
||||||
|
command: docker --version
|
||||||
|
register: docker_check
|
||||||
|
ignore_errors: yes
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Install Docker
|
||||||
|
shell: |
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
when: docker_check.rc != 0
|
||||||
|
|
||||||
|
- name: Create certificates directory
|
||||||
|
file:
|
||||||
|
path: "{{ cert_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create configuration directory
|
||||||
|
file:
|
||||||
|
path: "{{ config_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Pull HellreigN Agent image
|
||||||
|
community.docker.docker_image:
|
||||||
|
name: "{{ image }}"
|
||||||
|
source: pull
|
||||||
|
|
||||||
|
- name: Create agent configuration
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
|
||||||
|
label: "{{ agent_label }}"
|
||||||
|
registration_token: "{{ agent_token }}"
|
||||||
|
cert_dir: "{{ cert_dir }}"
|
||||||
|
services:
|
||||||
|
- name: "{{ agent_label }}"
|
||||||
|
type: docker
|
||||||
|
path: "{{ container_name }}"
|
||||||
|
dest: "{{ config_dir }}/config.yml"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Create and run HellreigN Agent container
|
||||||
|
community.docker.docker_container:
|
||||||
|
name: "{{ container_name }}"
|
||||||
|
image: "{{ image }}"
|
||||||
|
state: started
|
||||||
|
restart_policy: always
|
||||||
|
volumes:
|
||||||
|
- "{{ cert_dir }}:/etc/hellreign-agent/certs"
|
||||||
|
- "{{ config_dir }}/config.yml:/etc/hellreign-agent/config.yml:ro"
|
||||||
|
env:
|
||||||
|
CONFIG_FILE: /etc/hellreign-agent/config.yml
|
||||||
|
`
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
// This package contains embedded Ansible templates for playbooks and inventory generation.
|
||||||
|
// All templates are defined in playbooks.go and inventory.go.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DepCondition represents how a service waits for a dependency.
|
||||||
|
type DepCondition string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Started DepCondition = "started"
|
||||||
|
Healthy DepCondition = "healthy"
|
||||||
|
CompletedSuccessfully DepCondition = "completed_successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceRef uniquely identifies a service across nodes.
|
||||||
|
// If NodeID is empty, it refers to a service in the same node.
|
||||||
|
type ServiceRef struct {
|
||||||
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a human-readable reference like "node:service" or just "service".
|
||||||
|
func (r ServiceRef) String() string {
|
||||||
|
if r.NodeID != "" {
|
||||||
|
return r.NodeID + ":" + r.Name
|
||||||
|
}
|
||||||
|
return r.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency declares that a service depends on another service (possibly in a different node).
|
||||||
|
type Dependency struct {
|
||||||
|
Target ServiceRef `json:"target"`
|
||||||
|
Condition DepCondition `json:"condition"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service represents a named service within a node with its dependency declarations.
|
||||||
|
type Service struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Dependencies []Dependency `json:"dependencies,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node represents a logical grouping of services (e.g., a server or cluster).
|
||||||
|
type Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Services []*Service `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph holds nodes, services, and computes dependency order.
|
||||||
|
type Graph struct {
|
||||||
|
nodes map[string]*Node
|
||||||
|
// adj[key] = list of services that key depends on
|
||||||
|
// key format: "nodeID:serviceName"
|
||||||
|
adj map[string][]ServiceRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Graph {
|
||||||
|
return &Graph{
|
||||||
|
nodes: make(map[string]*Node),
|
||||||
|
adj: make(map[string][]ServiceRef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNode adds a node to the graph.
|
||||||
|
func (g *Graph) AddNode(nodeID string) *Node {
|
||||||
|
if n, ok := g.nodes[nodeID]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
n := &Node{ID: nodeID}
|
||||||
|
g.nodes[nodeID] = n
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddService adds a service to a node.
|
||||||
|
func (g *Graph) AddService(nodeID string, svc *Service) {
|
||||||
|
node := g.AddNode(nodeID)
|
||||||
|
node.Services = append(node.Services, svc)
|
||||||
|
key := nodeID + ":" + svc.Name
|
||||||
|
g.adj[key] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRef resolves a ServiceRef to its full "nodeID:serviceName" key.
|
||||||
|
// If ref.NodeID is empty, it's resolved relative to the given sourceNodeID.
|
||||||
|
func (g *Graph) ResolveRef(ref ServiceRef, sourceNodeID string) (string, error) {
|
||||||
|
nodeID := ref.NodeID
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = sourceNodeID
|
||||||
|
}
|
||||||
|
key := nodeID + ":" + ref.Name
|
||||||
|
if _, ok := g.adj[key]; !ok {
|
||||||
|
return "", fmt.Errorf("unknown service %q", key)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDependency adds a dependency: source service depends on target service.
|
||||||
|
func (g *Graph) AddDependency(sourceNodeID, sourceName string, dep Dependency) error {
|
||||||
|
srcKey := sourceNodeID + ":" + sourceName
|
||||||
|
if _, ok := g.adj[srcKey]; !ok {
|
||||||
|
return fmt.Errorf("unknown source service %q", srcKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := g.ResolveRef(dep.Target, sourceNodeID); err != nil {
|
||||||
|
return fmt.Errorf("dependency target invalid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.adj[srcKey] = append(g.adj[srcKey], dep.Target)
|
||||||
|
|
||||||
|
// Also update the Service struct for serialization
|
||||||
|
node, ok := g.nodes[sourceNodeID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, svc := range node.Services {
|
||||||
|
if svc.Name == sourceName {
|
||||||
|
svc.Dependencies = append(svc.Dependencies, dep)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCycle detects if the dependency graph contains a cycle.
|
||||||
|
func (g *Graph) HasCycle() bool {
|
||||||
|
const (
|
||||||
|
white = 0
|
||||||
|
gray = 1
|
||||||
|
black = 2
|
||||||
|
)
|
||||||
|
color := make(map[string]int)
|
||||||
|
for key := range g.adj {
|
||||||
|
color[key] = white
|
||||||
|
}
|
||||||
|
|
||||||
|
var dfs func(string) bool
|
||||||
|
dfs = func(u string) bool {
|
||||||
|
color[u] = gray
|
||||||
|
for _, depRef := range g.adj[u] {
|
||||||
|
v, _ := g.ResolveRef(depRef, nodeIDFromKey(u))
|
||||||
|
if color[v] == gray {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if color[v] == white && dfs(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
color[u] = black
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range g.adj {
|
||||||
|
if color[key] == white {
|
||||||
|
if dfs(key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopologicalSort returns services in startup order (dependencies first).
|
||||||
|
// Returns a flat list of "nodeID:serviceName" keys.
|
||||||
|
func (g *Graph) TopologicalSort() ([]string, error) {
|
||||||
|
if g.HasCycle() {
|
||||||
|
return nil, fmt.Errorf("dependency cycle detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
|
||||||
|
var dfs func(string)
|
||||||
|
dfs = func(u string) {
|
||||||
|
if visited[u] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visited[u] = true
|
||||||
|
for _, depRef := range g.adj[u] {
|
||||||
|
v, _ := g.ResolveRef(depRef, nodeIDFromKey(u))
|
||||||
|
dfs(v)
|
||||||
|
}
|
||||||
|
result = append(result, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(g.adj))
|
||||||
|
for k := range g.adj {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
dfs(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNode returns a node by ID.
|
||||||
|
func (g *Graph) GetNode(id string) (*Node, bool) {
|
||||||
|
n, ok := g.nodes[id]
|
||||||
|
return n, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetService returns a service by node ID and name.
|
||||||
|
func (g *Graph) GetService(nodeID, name string) (*Service, bool) {
|
||||||
|
node, ok := g.nodes[nodeID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, s := range node.Services {
|
||||||
|
if s.Name == name {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes returns all nodes sorted by ID.
|
||||||
|
func (g *Graph) Nodes() []*Node {
|
||||||
|
result := make([]*Node, 0, len(g.nodes))
|
||||||
|
for _, n := range g.nodes {
|
||||||
|
result = append(result, n)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].ID < result[j].ID
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeIDFromKey extracts the node ID from a "nodeID:serviceName" key.
|
||||||
|
func nodeIDFromKey(key string) string {
|
||||||
|
for i := 0; i < len(key); i++ {
|
||||||
|
if key[i] == ':' {
|
||||||
|
return key[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// yamlNode is the intermediate YAML representation of a node.
|
||||||
|
type yamlNode struct {
|
||||||
|
Services map[string]yamlService `yaml:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlService is the intermediate YAML representation of a service.
|
||||||
|
type yamlService struct {
|
||||||
|
DependsOn yamlDependsOn `yaml:"depends_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlDependsOn supports both short form (list of strings) and long form (map with conditions).
|
||||||
|
type yamlDependsOn struct {
|
||||||
|
simple []string
|
||||||
|
detail map[string]yamlDepCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
type yamlDepCondition struct {
|
||||||
|
Condition DepCondition `yaml:"condition"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *yamlDependsOn) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
switch value.Kind {
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var names []string
|
||||||
|
if err := value.Decode(&names); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.simple = names
|
||||||
|
return nil
|
||||||
|
case yaml.MappingNode:
|
||||||
|
d.detail = make(map[string]yamlDepCondition)
|
||||||
|
if err := value.Decode(&d.detail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("depends_on must be a list or mapping, got %v", value.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseServiceRef parses a reference like "redis" or "infra:redis".
|
||||||
|
func parseServiceRef(ref string) ServiceRef {
|
||||||
|
parts := strings.SplitN(ref, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return ServiceRef{NodeID: parts[0], Name: parts[1]}
|
||||||
|
}
|
||||||
|
return ServiceRef{Name: parts[0]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseYAML parses a node/service dependency graph from YAML bytes.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// nodes:
|
||||||
|
// server1:
|
||||||
|
// services:
|
||||||
|
// web:
|
||||||
|
// agent_id: agent-1
|
||||||
|
// depends_on:
|
||||||
|
// - redis
|
||||||
|
// - infra:cache
|
||||||
|
// api:
|
||||||
|
// depends_on:
|
||||||
|
// redis:
|
||||||
|
// condition: healthy
|
||||||
|
// infra:
|
||||||
|
// services:
|
||||||
|
// cache:
|
||||||
|
// db:
|
||||||
|
func ParseYAML(data []byte) (*Graph, error) {
|
||||||
|
var raw struct {
|
||||||
|
Nodes map[string]yamlNode `yaml:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse yaml: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g := New()
|
||||||
|
|
||||||
|
// Phase 1: register all nodes and services
|
||||||
|
for nodeID, yn := range raw.Nodes {
|
||||||
|
g.AddNode(nodeID)
|
||||||
|
for svcName := range yn.Services {
|
||||||
|
g.AddService(nodeID, &Service{Name: svcName})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: wire dependencies
|
||||||
|
for nodeID, yn := range raw.Nodes {
|
||||||
|
for svcName, ys := range yn.Services {
|
||||||
|
// Short form
|
||||||
|
for _, ref := range ys.DependsOn.simple {
|
||||||
|
target := parseServiceRef(ref)
|
||||||
|
if err := g.AddDependency(nodeID, svcName, Dependency{
|
||||||
|
Target: target,
|
||||||
|
Condition: Started,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Long form
|
||||||
|
for ref, cond := range ys.DependsOn.detail {
|
||||||
|
target := parseServiceRef(ref)
|
||||||
|
if err := g.AddDependency(nodeID, svcName, Dependency{
|
||||||
|
Target: target,
|
||||||
|
Condition: cond.Condition,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseYAMLFile reads and parses from a file.
|
||||||
|
func ParseYAMLFile(path string) (*Graph, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseYAML(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collector handles log streaming from connected agents.
|
||||||
|
type Collector struct {
|
||||||
|
proto.UnimplementedCollectorServer
|
||||||
|
logRepo *repository.LogRepository
|
||||||
|
tracker *ConnTracker
|
||||||
|
batchSize int
|
||||||
|
flushInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logRepo *repository.LogRepository, tracker *ConnTracker) *Collector {
|
||||||
|
return &Collector{
|
||||||
|
logRepo: logRepo,
|
||||||
|
tracker: tracker,
|
||||||
|
batchSize: 100,
|
||||||
|
flushInterval: 2 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
|
||||||
|
md, ok := metadata.FromIncomingContext(stream.Context())
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no metadata in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
whoamiVals := md["whoami"]
|
||||||
|
if len(whoamiVals) == 0 {
|
||||||
|
return fmt.Errorf("whoami metadata missing")
|
||||||
|
}
|
||||||
|
agentName := whoamiVals[0]
|
||||||
|
|
||||||
|
serviceVals := md["service"]
|
||||||
|
if len(serviceVals) == 0 {
|
||||||
|
return fmt.Errorf("service metadata missing")
|
||||||
|
}
|
||||||
|
service := serviceVals[0]
|
||||||
|
|
||||||
|
agent := &Agent{
|
||||||
|
ID: agentName,
|
||||||
|
Label: agentName,
|
||||||
|
Services: make([]Service, 0),
|
||||||
|
ConnectedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tracker.Register(agent)
|
||||||
|
defer c.tracker.Unregister(agent.ID)
|
||||||
|
|
||||||
|
log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service)
|
||||||
|
|
||||||
|
// If no ClickHouse, just consume the stream without storing
|
||||||
|
if !c.logRepo.IsConnected() {
|
||||||
|
log.Printf(
|
||||||
|
"Warning: ClickHouse not connected yet, consuming logs without storing for agent %s",
|
||||||
|
agentName,
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to receive: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels for communication with recv goroutine
|
||||||
|
recvCh := make(chan *proto.CollectorRequest, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
// Goroutine that blocks on Recv
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
req, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recvCh <- req
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Buffer for batch inserts
|
||||||
|
var batch []storage.LogEntry
|
||||||
|
ticker := time.NewTicker(c.flushInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
flush := func() error {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.logRepo.InsertBatch(stream.Context(), batch); err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"Failed to insert batch for agent %s, service %s: %v",
|
||||||
|
agentName,
|
||||||
|
service,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service)
|
||||||
|
batch = batch[:0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stream.Context().Done():
|
||||||
|
_ = flush()
|
||||||
|
return stream.Context().Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case req := <-recvCh:
|
||||||
|
batch = append(batch, storage.LogEntry{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Level: "info",
|
||||||
|
Service: service,
|
||||||
|
Agent: agentName,
|
||||||
|
Message: req.Message,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(batch) >= c.batchSize {
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case err := <-errCh:
|
||||||
|
if err == io.EOF {
|
||||||
|
return flush()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to receive: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent delegates to the tracker.
|
||||||
|
func (c *Collector) GetAgent(name string) (*Agent, bool) {
|
||||||
|
return c.tracker.GetAgent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents delegates to the tracker.
|
||||||
|
func (c *Collector) Agents() []*Agent {
|
||||||
|
return c.tracker.Agents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemMetrics delegates to the tracker.
|
||||||
|
func (c *Collector) GetSystemMetrics() map[string]AgentMetricsInfo {
|
||||||
|
return c.tracker.GetSystemMetrics()
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportServices handles a unary service status update from an agent.
|
||||||
|
// Agents send their current services list, which is stored in the collector.
|
||||||
|
func (c *Collector) ReportServices(ctx context.Context, req *proto.ServicesUpdate) (*proto.ServicesUpdateResp, error) {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no metadata in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
whoamiVals := md["whoami"]
|
||||||
|
if len(whoamiVals) == 0 {
|
||||||
|
return nil, fmt.Errorf("whoami metadata missing")
|
||||||
|
}
|
||||||
|
agentName := whoamiVals[0]
|
||||||
|
|
||||||
|
// Auto-register agent if not yet known (e.g. log stream not connected yet)
|
||||||
|
c.ensureAgentRegistered(agentName)
|
||||||
|
|
||||||
|
services := make([]Service, 0, len(req.Services))
|
||||||
|
for _, s := range req.Services {
|
||||||
|
services = append(services, Service{s.Name, s.Status})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := c.tracker.UpdateServices(agentName, services); ok {
|
||||||
|
log.Printf("Updated services for agent %s: %v", agentName, services)
|
||||||
|
} else {
|
||||||
|
log.Printf("Warning: received services update for unknown agent %s", agentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &proto.ServicesUpdateResp{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportSystemMetrics handles system metrics update from an agent.
|
||||||
|
// Agents send their current system metrics (CPU, RAM, disk, network).
|
||||||
|
func (c *Collector) ReportSystemMetrics(ctx context.Context, req *proto.SystemMetrics) (*proto.SystemMetricsResp, error) {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no metadata in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
whoamiVals := md["whoami"]
|
||||||
|
if len(whoamiVals) == 0 {
|
||||||
|
return nil, fmt.Errorf("whoami metadata missing")
|
||||||
|
}
|
||||||
|
agentName := whoamiVals[0]
|
||||||
|
|
||||||
|
// Auto-register agent if not yet known (e.g. log stream not connected yet)
|
||||||
|
c.ensureAgentRegistered(agentName)
|
||||||
|
|
||||||
|
metrics := SystemMetrics{
|
||||||
|
CPUPercent: req.CpuPercent,
|
||||||
|
MemoryPercent: req.MemoryPercent,
|
||||||
|
DiskPercent: req.DiskPercent,
|
||||||
|
NetworkRxBytes: req.NetworkRxBytes,
|
||||||
|
NetworkTxBytes: req.NetworkTxBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := c.tracker.UpdateSystemMetrics(agentName, metrics); ok {
|
||||||
|
log.Printf("Updated system metrics for agent %s: CPU=%.1f%%, RAM=%.1f%%, Disk=%.1f%%",
|
||||||
|
agentName, metrics.CPUPercent, metrics.MemoryPercent, metrics.DiskPercent)
|
||||||
|
} else {
|
||||||
|
log.Printf("Warning: received system metrics for unknown agent %s", agentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &proto.SystemMetricsResp{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAgentRegistered registers the agent in the tracker if it's not already there.
|
||||||
|
// This handles the case where agents send metrics/services before connecting to the log stream.
|
||||||
|
func (c *Collector) ensureAgentRegistered(agentName string) {
|
||||||
|
if _, ok := c.tracker.GetAgent(agentName); !ok {
|
||||||
|
agent := &Agent{
|
||||||
|
ID: agentName,
|
||||||
|
Label: agentName,
|
||||||
|
Services: make([]Service, 0),
|
||||||
|
ConnectedAt: time.Now(),
|
||||||
|
}
|
||||||
|
c.tracker.Register(agent)
|
||||||
|
log.Printf("Auto-registered agent via unary RPC: %s", agentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnTracker tracks connected Collector agents and handles cleanup on disconnect.
|
||||||
|
// It implements grpc.StatsHandler for disconnect detection.
|
||||||
|
type ConnTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
agents map[string]*Agent
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnTracker() *ConnTracker {
|
||||||
|
return &ConnTracker{
|
||||||
|
agents: make(map[string]*Agent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds an agent to the tracker. Called by Collector.Stream().
|
||||||
|
func (t *ConnTracker) Register(agent *Agent) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.agents[agent.ID] = agent
|
||||||
|
t.mu.Unlock()
|
||||||
|
log.Printf("[collector] agent registered: %s", agent.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes an agent from the tracker.
|
||||||
|
func (t *ConnTracker) Unregister(id string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
delete(t.agents, id)
|
||||||
|
t.mu.Unlock()
|
||||||
|
log.Printf("[collector] agent unregistered: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent returns the agent for the given ID.
|
||||||
|
func (t *ConnTracker) GetAgent(id string) (*Agent, bool) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
a, ok := t.agents[id]
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents returns all connected agents.
|
||||||
|
func (t *ConnTracker) Agents() []*Agent {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
result := make([]*Agent, 0, len(t.agents))
|
||||||
|
for _, a := range t.agents {
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// grpc.StatsHandler implementation.
|
||||||
|
|
||||||
|
func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
|
||||||
|
|
||||||
|
func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) HandleConn(ctx context.Context, s stats.ConnStats) {
|
||||||
|
switch s.(type) {
|
||||||
|
case *stats.ConnEnd:
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
whoamiVals := md["whoami"]
|
||||||
|
if len(whoamiVals) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Unregister(whoamiVals[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServices updates the services list for the given agent.
|
||||||
|
func (t *ConnTracker) UpdateServices(id string, services []Service) bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
agent, ok := t.agents[id]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
agent.Services = services
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSystemMetrics updates the system metrics for the given agent.
|
||||||
|
func (t *ConnTracker) UpdateSystemMetrics(id string, metrics SystemMetrics) bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
agent, ok := t.agents[id]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
agent.SystemMetrics = metrics
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemMetrics returns system metrics for all connected agents.
|
||||||
|
func (t *ConnTracker) GetSystemMetrics() map[string]AgentMetricsInfo {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
result := make(map[string]AgentMetricsInfo)
|
||||||
|
for id, agent := range t.agents {
|
||||||
|
result[id] = AgentMetricsInfo{
|
||||||
|
ID: id,
|
||||||
|
Label: agent.Label,
|
||||||
|
ConnectedAt: agent.ConnectedAt,
|
||||||
|
CPUPercent: agent.SystemMetrics.CPUPercent,
|
||||||
|
MemoryPercent: agent.SystemMetrics.MemoryPercent,
|
||||||
|
DiskPercent: agent.SystemMetrics.DiskPercent,
|
||||||
|
NetworkRxBytes: agent.SystemMetrics.NetworkRxBytes,
|
||||||
|
NetworkTxBytes: agent.SystemMetrics.NetworkTxBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service represents a named service with its current status.
|
||||||
|
type Service struct {
|
||||||
|
Name, Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemMetrics represents system resource metrics.
|
||||||
|
type SystemMetrics struct {
|
||||||
|
CPUPercent float64
|
||||||
|
MemoryPercent float64
|
||||||
|
DiskPercent float64
|
||||||
|
NetworkRxBytes float64
|
||||||
|
NetworkTxBytes float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentMetricsInfo contains agent info with its system metrics.
|
||||||
|
type AgentMetricsInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
ConnectedAt time.Time `json:"connected_at"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemoryPercent float64 `json:"memory_percent"`
|
||||||
|
DiskPercent float64 `json:"disk_percent"`
|
||||||
|
NetworkRxBytes float64 `json:"network_rx_bytes"`
|
||||||
|
NetworkTxBytes float64 `json:"network_tx_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent represents a connected agent streaming logs to the collector.
|
||||||
|
type Agent struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Services []Service
|
||||||
|
SystemMetrics SystemMetrics
|
||||||
|
ConnectedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package commander
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Commander handles command execution on connected agents.
|
||||||
|
type Commander struct {
|
||||||
|
proto.UnimplementedCommanderServer
|
||||||
|
tracker *ConnTracker
|
||||||
|
jobber Jobber
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobber persists job state.
|
||||||
|
type Jobber interface {
|
||||||
|
InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error)
|
||||||
|
UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(jobber Jobber, tracker *ConnTracker) *Commander {
|
||||||
|
return &Commander{
|
||||||
|
jobber: jobber,
|
||||||
|
tracker: tracker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent represents a connected agent with an active bidirectional stream.
|
||||||
|
type Agent struct {
|
||||||
|
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]
|
||||||
|
in chan *proto.Command
|
||||||
|
jobs map[int64]Job
|
||||||
|
jobber Jobber
|
||||||
|
ctx context.Context
|
||||||
|
aid string
|
||||||
|
|
||||||
|
Token string
|
||||||
|
Label string
|
||||||
|
Services []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobOut struct {
|
||||||
|
fc models.Job
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
out chan JobOut
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnTracker tracks connected agents and handles cleanup on disconnect.
|
||||||
|
// It implements grpc.StatsHandler for disconnect detection.
|
||||||
|
type ConnTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
agents map[string]*Agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentByLabel searches for an agent by its human-readable label.
|
||||||
|
func (self *ConnTracker) GetAgentByLabel(label string) (agent Agent, ok bool) {
|
||||||
|
self.mu.RLock()
|
||||||
|
defer self.mu.RUnlock()
|
||||||
|
for _, a := range self.agents {
|
||||||
|
if a.Label == label {
|
||||||
|
return *a, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnTracker() *ConnTracker {
|
||||||
|
return &ConnTracker{
|
||||||
|
agents: make(map[string]*Agent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) Register(aid string, agent *Agent) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.agents[aid] = agent
|
||||||
|
t.mu.Unlock()
|
||||||
|
log.Printf("[conntracker] agent registered: %s", aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) Unregister(aid string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
delete(t.agents, aid)
|
||||||
|
t.mu.Unlock()
|
||||||
|
log.Printf("[conntracker] agent unregistered: %s", aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) GetAgent(aid string) (*Agent, bool) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
a, ok := t.agents[aid]
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) Agents() []*Agent {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
result := make([]*Agent, 0, len(t.agents))
|
||||||
|
for _, a := range t.agents {
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// grpc.StatsHandler implementation.
|
||||||
|
|
||||||
|
func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
|
||||||
|
|
||||||
|
func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTracker) HandleConn(ctx context.Context, s stats.ConnStats) {
|
||||||
|
switch s.(type) {
|
||||||
|
case *stats.ConnEnd:
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aidVals := md["agentid"]
|
||||||
|
if len(aidVals) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Unregister(aidVals[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream handles a new agent connection and runs the send/recv loops.
|
||||||
|
func (c *Commander) Stream(
|
||||||
|
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command],
|
||||||
|
) error {
|
||||||
|
md, ok := metadata.FromIncomingContext(bidi.Context())
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no metadata in context")
|
||||||
|
}
|
||||||
|
aidVals := md["agentid"]
|
||||||
|
if len(aidVals) == 0 {
|
||||||
|
return fmt.Errorf("agentid metadata missing")
|
||||||
|
}
|
||||||
|
aid := aidVals[0]
|
||||||
|
|
||||||
|
var label string
|
||||||
|
if vals := md["label"]; len(vals) > 0 {
|
||||||
|
label = vals[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgent(bidi.Context(), c.jobber, aid, label)
|
||||||
|
agent.bidi = bidi
|
||||||
|
|
||||||
|
c.tracker.Register(aid, agent)
|
||||||
|
defer c.tracker.Unregister(aid)
|
||||||
|
|
||||||
|
return agent.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent returns the agent by ID. Delegates to the tracker.
|
||||||
|
func (c *Commander) GetAgent(aid string) (*Agent, bool) {
|
||||||
|
return c.tracker.GetAgent(aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) AddJob(job models.JobForInsert) (int64, error) {
|
||||||
|
jid, err := a.jobber.InitJob(a.ctx, a.aid, job)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
a.jobs[jid] = newJob()
|
||||||
|
a.in <- &proto.Command{
|
||||||
|
Id: jid,
|
||||||
|
Command: job.Command,
|
||||||
|
Stdin: job.Stdin,
|
||||||
|
}
|
||||||
|
return jid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) WaitJob(jid int64) (*models.Job, error) {
|
||||||
|
result := <-a.jobs[jid].out
|
||||||
|
return &result.fc, result.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) run() error {
|
||||||
|
wg := new(errgroup.Group)
|
||||||
|
wg.Go(a.recv)
|
||||||
|
wg.Go(a.send)
|
||||||
|
return wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) recv() error {
|
||||||
|
for {
|
||||||
|
job, err := func() (job models.Job, err error) {
|
||||||
|
msg, err := a.bidi.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return a.jobber.UpdateJobInDB(a.ctx, msg.Id, models.JobForUpdate{
|
||||||
|
Stdout: msg.Stdout,
|
||||||
|
Stderr: msg.Stderr,
|
||||||
|
Status: msg.Status,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := a.jobs[job.ID].out
|
||||||
|
out <- JobOut{
|
||||||
|
fc: job,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
close(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) send() error {
|
||||||
|
for job := range a.in {
|
||||||
|
if err := a.bidi.Send(job); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
jobber Jobber,
|
||||||
|
aid string,
|
||||||
|
label string,
|
||||||
|
) *Agent {
|
||||||
|
return &Agent{
|
||||||
|
in: make(chan *proto.Command, 10),
|
||||||
|
jobs: make(map[int64]Job),
|
||||||
|
jobber: jobber,
|
||||||
|
ctx: ctx,
|
||||||
|
aid: aid,
|
||||||
|
Label: label,
|
||||||
|
Token: aid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJob() Job {
|
||||||
|
return Job{make(chan JobOut, 1)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/ansible"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentDeployGroup struct {
|
||||||
|
*Handlers
|
||||||
|
executor *ansible.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
|
||||||
|
workDir := os.Getenv("ANSIBLE_WORK_DIR")
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = "/tmp/hellreign/ansible"
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcPort := os.Getenv("GRPC_PORT")
|
||||||
|
if grpcPort == "" {
|
||||||
|
grpcPort = "9001"
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcHost := os.Getenv("GRPC_SERVER_HOST")
|
||||||
|
if grpcHost == "" {
|
||||||
|
grpcHost = "0.0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
backendURL := os.Getenv("BACKEND_URL")
|
||||||
|
if backendURL == "" {
|
||||||
|
backendURL = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
giteaURL := os.Getenv("GITEA_RELEASES_URL")
|
||||||
|
if giteaURL == "" {
|
||||||
|
giteaURL = "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download"
|
||||||
|
}
|
||||||
|
|
||||||
|
exec := ansible.NewExecutor(ansible.ExecutorConfig{
|
||||||
|
WorkDir: workDir,
|
||||||
|
GRPCServerHost: grpcHost,
|
||||||
|
GRPCServerPort: grpcPort,
|
||||||
|
BackendURL: backendURL,
|
||||||
|
GiteaReleasesURL: giteaURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write playbooks on init
|
||||||
|
if err := exec.WriteAllPlaybooks(); err != nil {
|
||||||
|
// Log the error - deployment will fail later if playbooks can't be written
|
||||||
|
fmt.Fprintf(os.Stderr, "WARNING: failed to write Ansible playbooks: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AgentDeployGroup{
|
||||||
|
Handlers: h,
|
||||||
|
executor: exec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployAgents deploys agents to multiple servers
|
||||||
|
// @Summary Deploy agents to multiple servers via Ansible
|
||||||
|
// @Description Deploy HellreigN agents to multiple servers using Ansible playbooks. Supports Docker and Binary deployment types.
|
||||||
|
// @Tags agents
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body repository.DeployAgentsRequest true "Deployment configuration for servers"
|
||||||
|
// @Success 200 {object} repository.DeployResponse "Deployment results with tokens for each server"
|
||||||
|
// @Failure 400 {object} map[string]string "Invalid request"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /agents/deploy [post]
|
||||||
|
func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
||||||
|
var req repository.DeployAgentsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auth credentials for each server
|
||||||
|
for i, server := range req.Servers {
|
||||||
|
switch server.AuthMethod {
|
||||||
|
case repository.AuthMethodKey:
|
||||||
|
if server.SSHKey == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("server %d (%s): sshKey is required when authMethod is 'key'", i, server.IP),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case repository.AuthMethodPassword:
|
||||||
|
if server.Password == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("server %d (%s): password is required when authMethod is 'password'", i, server.IP),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("server %d (%s): invalid authMethod %q, expected 'key' or 'password'", i, server.IP, server.AuthMethod),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight check: verify community.docker collection is available for docker deployments
|
||||||
|
needsDockerCollection := false
|
||||||
|
for _, server := range req.Servers {
|
||||||
|
if server.DeployType == repository.DeployTypeDocker {
|
||||||
|
needsDockerCollection = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needsDockerCollection {
|
||||||
|
if err := adg.executor.CheckDockerCollection(); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("Docker deployment requires 'community.docker' Ansible collection: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create work directory
|
||||||
|
workDir := adg.executor.WorkDir()
|
||||||
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create work directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate registration tokens for each server
|
||||||
|
results := make([]repository.DeployResult, 0, len(req.Servers))
|
||||||
|
timestamp := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for i, server := range req.Servers {
|
||||||
|
// Create registration token
|
||||||
|
token, err := adg.Repo.CreateRegistrationToken(server.AgentLabel)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create token: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default port
|
||||||
|
port := server.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate inventory for this single server
|
||||||
|
inventoryHosts := []ansible.InventoryHost{
|
||||||
|
{
|
||||||
|
Name: server.AgentLabel,
|
||||||
|
IP: server.IP,
|
||||||
|
Port: port,
|
||||||
|
User: server.User,
|
||||||
|
AuthMethod: string(server.AuthMethod),
|
||||||
|
SSHKey: server.SSHKey,
|
||||||
|
Password: server.Password,
|
||||||
|
DeployType: string(server.DeployType),
|
||||||
|
Token: token,
|
||||||
|
GRPCURL: adg.executor.GRPCURL(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
|
||||||
|
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
|
||||||
|
// Rollback: delete the token we just created
|
||||||
|
_ = adg.Repo.DeleteRegistrationToken(token)
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Token: token,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to generate inventory: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run Ansible playbook for this server
|
||||||
|
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
|
||||||
|
|
||||||
|
// Clean up inventory file (log error but don't fail deployment)
|
||||||
|
if cleanupErr := os.Remove(inventoryPath); cleanupErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARNING: failed to remove inventory file %s: %v\n", inventoryPath, cleanupErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: delete the token since deployment failed
|
||||||
|
_ = adg.Repo.DeleteRegistrationToken(token)
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Token: token,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("deployment failed: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
success := true
|
||||||
|
errMsg := ""
|
||||||
|
if len(deployResults) > 0 && !deployResults[0].Success {
|
||||||
|
success = false
|
||||||
|
errMsg = deployResults[0].Stderr
|
||||||
|
// Rollback: delete the token since ansible playbook reported failure
|
||||||
|
_ = adg.Repo.DeleteRegistrationToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Token: token,
|
||||||
|
Success: success,
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, repository.DeployResponse{
|
||||||
|
Message: "Deployment completed",
|
||||||
|
Results: results,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentRegistrationGroup struct {
|
||||||
|
*Handlers
|
||||||
|
certBundle *utils.CertBundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentRegistrationGroup(h *Handlers) *AgentRegistrationGroup {
|
||||||
|
certDir := getCertDir()
|
||||||
|
bundle, err := utils.LoadCertBundle(certDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[agent-reg] WARNING: cert bundle load failed: %v", err)
|
||||||
|
}
|
||||||
|
return &AgentRegistrationGroup{
|
||||||
|
Handlers: h,
|
||||||
|
certBundle: bundle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistrationToken — админ создаёт токен для агента
|
||||||
|
// @Summary Create registration token
|
||||||
|
// @Tags agents
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body repository.RegistrationRequest true "Label"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /agents/register-token [post]
|
||||||
|
func (arg *AgentRegistrationGroup) CreateRegistrationToken(c *gin.Context) {
|
||||||
|
var req repository.RegistrationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := arg.Repo.CreateRegistrationToken(req.Label)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register — агент шлёт CSR + token, получает сертификаты
|
||||||
|
// @Summary Register agent
|
||||||
|
// @Tags agents
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body RegisterRequest true "CSR + token"
|
||||||
|
// @Success 200 {object} RegisterResponse
|
||||||
|
// @Router /agents/register [post]
|
||||||
|
func (arg *AgentRegistrationGroup) Register(c *gin.Context) {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if arg.certBundle == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "certificate bundle not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
regToken, err := arg.Repo.GetRegistrationToken(req.Token)
|
||||||
|
if err != nil {
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid registration token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if regToken.Used {
|
||||||
|
c.JSON(http.StatusGone, gin.H{"error": "registration token already used"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCertPEM, err := arg.certBundle.SignCSR([]byte(req.CSR), regToken.Label)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to sign CSR: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := arg.Repo.MarkRegistrationTokenUsed(req.Token); err != nil {
|
||||||
|
log.Printf("[agent-reg] WARNING: failed to mark token used: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, RegisterResponse{
|
||||||
|
CACert: string(arg.certBundle.GetCACertPEM()),
|
||||||
|
ClientCert: string(clientCertPEM),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
CSR string `json:"csr" binding:"required"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
ClientCert string `json:"client_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCertDir() string {
|
||||||
|
if d := os.Getenv("SSL_CERT_DIR"); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return "/var/lib/hellreign/ssl"
|
||||||
|
}
|
||||||
@@ -1,26 +1,94 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentsGroup struct {
|
type AgentsGroup struct {
|
||||||
*Handlers
|
*Handlers
|
||||||
|
collector *collector.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewAgentsGroup(h *Handlers, coll *collector.Collector) AgentsGroup {
|
||||||
|
return AgentsGroup{Handlers: h, collector: coll}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentInfo represents a connected agent's current status.
|
||||||
type AgentInfo struct {
|
type AgentInfo struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token" example:"agent-001"` // Unique agent identifier
|
||||||
Label string `json:"label"`
|
Label string `json:"label" example:"web-server-1"` // Human-readable label
|
||||||
Services []string `json:"services"`
|
Services []string `json:"services" example:"nginx:running,redis:up"` // List of services with status (format: "name:status")
|
||||||
|
ConnectedAt string `json:"connected_at" example:"2026-04-04 10:30:00"` // Time when agent connected (RFC3339-like)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Get connected agents
|
// @Summary Get connected agents
|
||||||
// @Description Returns a list of all agents currently connected via gRPC streaming
|
// @Description Returns a list of all agents currently connected via Collector (log streaming)
|
||||||
// @Tags agents
|
// @Tags agents
|
||||||
|
// @Security Bearer
|
||||||
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} AgentInfo
|
// @Success 200 {array} AgentInfo
|
||||||
// @Router /agents [get]
|
// @Router /agents [get]
|
||||||
func (ag *AgentsGroup) List(c *gin.Context) {
|
func (ag *AgentsGroup) List(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Agents list"})
|
agents := make([]AgentInfo, 0)
|
||||||
|
|
||||||
|
for _, agent := range ag.collector.Agents() {
|
||||||
|
services := make([]string, 0, len(agent.Services))
|
||||||
|
for _, s := range agent.Services {
|
||||||
|
services = append(services, fmt.Sprintf("%s:%s", s.Name, s.Status))
|
||||||
|
}
|
||||||
|
agents = append(agents, AgentInfo{
|
||||||
|
Token: agent.ID,
|
||||||
|
Label: agent.Label,
|
||||||
|
Services: services,
|
||||||
|
ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSystemMetricsOut represents system metrics for a single agent.
|
||||||
|
type AgentSystemMetricsOut struct {
|
||||||
|
ID string `json:"id" example:"agent-001"`
|
||||||
|
Label string `json:"label" example:"web-server-1"`
|
||||||
|
ConnectedAt string `json:"connected_at" example:"2026-04-04 10:30:00"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent" example:"45.2"`
|
||||||
|
MemoryPercent float64 `json:"memory_percent" example:"62.5"`
|
||||||
|
DiskPercent float64 `json:"disk_percent" example:"78.9"`
|
||||||
|
NetworkRxBytes float64 `json:"network_rx_bytes" example:"1048576.0"`
|
||||||
|
NetworkTxBytes float64 `json:"network_tx_bytes" example:"524288.0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemMetrics returns system load metrics for all connected agents.
|
||||||
|
// @Summary Get agent system metrics
|
||||||
|
// @Description Returns CPU, RAM, disk, and network usage metrics for all connected agents
|
||||||
|
// @Tags agents
|
||||||
|
// @Security Bearer
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} AgentSystemMetricsOut
|
||||||
|
// @Router /agents/system-metrics [get]
|
||||||
|
func (ag *AgentsGroup) GetSystemMetrics(c *gin.Context) {
|
||||||
|
metricsMap := ag.collector.GetSystemMetrics()
|
||||||
|
|
||||||
|
metrics := make([]AgentSystemMetricsOut, 0, len(metricsMap))
|
||||||
|
for _, m := range metricsMap {
|
||||||
|
metrics = append(metrics, AgentSystemMetricsOut{
|
||||||
|
ID: m.ID,
|
||||||
|
Label: m.Label,
|
||||||
|
ConnectedAt: m.ConnectedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
CPUPercent: m.CPUPercent,
|
||||||
|
MemoryPercent: m.MemoryPercent,
|
||||||
|
DiskPercent: m.DiskPercent,
|
||||||
|
NetworkRxBytes: m.NetworkRxBytes,
|
||||||
|
NetworkTxBytes: m.NetworkTxBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, metrics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ type AuthGroup struct {
|
|||||||
// @Success 200 {object} repository.LoginResponse
|
// @Success 200 {object} repository.LoginResponse
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 403 {object} map[string]string
|
||||||
// @Router /auth/login [post]
|
// @Router /auth/login [post]
|
||||||
func (ag *AuthGroup) Login(c *gin.Context) {
|
func (ag *AuthGroup) Login(c *gin.Context) {
|
||||||
var req repository.LoginRequest
|
var req repository.LoginRequest
|
||||||
@@ -37,6 +40,10 @@ func (ag *AuthGroup) Login(c *gin.Context) {
|
|||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, repository.ErrAccountInactive) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "account is not activated by admin"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authenticate"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authenticate"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -44,6 +51,39 @@ func (ag *AuthGroup) Login(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterUser registers a new user with all permissions set to false.
|
||||||
|
// @Summary Register user
|
||||||
|
// @Description Registers a new user with login, password, name, last name. All permissions are set to false.
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param request body repository.UserRegister true "Registration data"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 409 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/register [post]
|
||||||
|
func (ag *AuthGroup) RegisterUser(c *gin.Context) {
|
||||||
|
var req repository.UserRegister
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := ag.Repo.RegisterUser(req)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "login already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[register] failed: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to register user: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[register] user registered: id=%s login=%s", id, req.Login)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user registered"})
|
||||||
|
}
|
||||||
|
|
||||||
// CreateToken creates a new user.
|
// CreateToken creates a new user.
|
||||||
// @Summary Create user
|
// @Summary Create user
|
||||||
// @Description Creates a new user with permissions
|
// @Description Creates a new user with permissions
|
||||||
@@ -54,6 +94,7 @@ func (ag *AuthGroup) Login(c *gin.Context) {
|
|||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /auth/token [post]
|
// @Router /auth/token [post]
|
||||||
func (ag *AuthGroup) CreateToken(c *gin.Context) {
|
func (ag *AuthGroup) CreateToken(c *gin.Context) {
|
||||||
var tc repository.TokenCreate
|
var tc repository.TokenCreate
|
||||||
@@ -77,6 +118,7 @@ func (ag *AuthGroup) CreateToken(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} repository.Tokens
|
// @Success 200 {object} repository.Tokens
|
||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /auth/validate [get]
|
// @Router /auth/validate [get]
|
||||||
func (ag *AuthGroup) ValidateToken(c *gin.Context) {
|
func (ag *AuthGroup) ValidateToken(c *gin.Context) {
|
||||||
tokenVal, exists := c.Get(string(tokenContextKey))
|
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||||
@@ -101,6 +143,7 @@ func (ag *AuthGroup) ValidateToken(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} repository.Tokens
|
// @Success 200 {array} repository.Tokens
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /auth/tokens [get]
|
// @Router /auth/tokens [get]
|
||||||
func (ag *AuthGroup) ListTokens(c *gin.Context) {
|
func (ag *AuthGroup) ListTokens(c *gin.Context) {
|
||||||
tokens, err := ag.Repo.ListTokens()
|
tokens, err := ag.Repo.ListTokens()
|
||||||
@@ -119,6 +162,7 @@ func (ag *AuthGroup) ListTokens(c *gin.Context) {
|
|||||||
// @Success 200 {object} map[string]string
|
// @Success 200 {object} map[string]string
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /auth/tokens/:login [delete]
|
// @Router /auth/tokens/:login [delete]
|
||||||
func (ag *AuthGroup) DeleteToken(c *gin.Context) {
|
func (ag *AuthGroup) DeleteToken(c *gin.Context) {
|
||||||
login := c.Param("login")
|
login := c.Param("login")
|
||||||
@@ -146,6 +190,7 @@ func (ag *AuthGroup) DeleteToken(c *gin.Context) {
|
|||||||
// @Success 200 {object} map[string]string
|
// @Success 200 {object} map[string]string
|
||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /auth/token [delete]
|
// @Router /auth/token [delete]
|
||||||
func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
|
func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
|
||||||
tokenVal, exists := c.Get(string(tokenContextKey))
|
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||||
@@ -168,6 +213,230 @@ func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "account deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "account deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivateUser activates a user by login.
|
||||||
|
// @Summary Activate user
|
||||||
|
// @Description Activates a user account by login (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Param login path string true "Login of the user to activate"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/:login/activate [post]
|
||||||
|
func (ag *AuthGroup) ActivateUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.ActivateUserByLogin(login); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to activate user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user activated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateUser deactivates a user by login.
|
||||||
|
// @Summary Deactivate user
|
||||||
|
// @Description Deactivates a user account by login (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Param login path string true "Login of the user to deactivate"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/:login/deactivate [post]
|
||||||
|
func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.DeactivateUserByLogin(login); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user deactivated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInactiveUsers returns all users that are not activated.
|
||||||
|
// @Summary List inactive users
|
||||||
|
// @Description Returns list of all users waiting for activation
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} repository.Tokens
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/inactive [get]
|
||||||
|
func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
|
||||||
|
tokens, err := ag.Repo.ListInactiveTokens()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list inactive users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a user by login.
|
||||||
|
// @Summary Get user by login
|
||||||
|
// @Description Returns a user by their login (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Success 200 {object} repository.Tokens
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/:login [get]
|
||||||
|
func (ag *AuthGroup) GetUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ag.Repo.GetTokenByLogin(login)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates user's name and last name.
|
||||||
|
// @Summary Update user
|
||||||
|
// @Description Updates a user's name and last name (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Param request body repository.TokenUpdate true "User data to update"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/:login [put]
|
||||||
|
func (ag *AuthGroup) UpdateUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var update repository.TokenUpdate
|
||||||
|
if err := c.ShouldBindJSON(&update); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.UpdateToken(login, update); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPermissions updates user's permissions and activation status.
|
||||||
|
// @Summary Update user permissions
|
||||||
|
// @Description Updates a user's permissions and activation status (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Param request body repository.TokenUpdatePermissions true "Permissions to update"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/:login/permissions [put]
|
||||||
|
func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var update repository.TokenUpdatePermissions
|
||||||
|
if err := c.ShouldBindJSON(&update); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.UpdatePermissions(login, update); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "permissions updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetUserPassword resets a user's password.
|
||||||
|
// @Summary Reset user password
|
||||||
|
// @Description Resets a user's password to a new value (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Param request body repository.TokenPasswordReset true "New password"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /auth/users/:login/password [put]
|
||||||
|
func (ag *AuthGroup) ResetUserPassword(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req repository.TokenPasswordReset
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.UpdatePassword(login, req.NewPassword); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||||
|
}
|
||||||
|
|
||||||
// getTokenFromHeader extracts the Bearer token from the Authorization header.
|
// getTokenFromHeader extracts the Bearer token from the Authorization header.
|
||||||
func getTokenFromHeader(c *gin.Context) string {
|
func getTokenFromHeader(c *gin.Context) string {
|
||||||
auth := c.GetHeader("Authorization")
|
auth := c.GetHeader("Authorization")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CorsMiddleware(origincfg string) gin.HandlerFunc {
|
||||||
|
origins := strings.Split(origincfg, ";")
|
||||||
|
if origins[0] == "" {
|
||||||
|
panic("zero cors origins wtf is your config")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if !lo.Contains(origins, origin) {
|
||||||
|
origin = origins[0]
|
||||||
|
}
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
// c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Writer.Header().
|
||||||
|
Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, Authorization")
|
||||||
|
c.Writer.Header().
|
||||||
|
Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PATCH, DELETE, PUT")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/graph"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GraphHandlers manages the service dependency graph.
|
||||||
|
type GraphHandlers struct {
|
||||||
|
path string
|
||||||
|
mu sync.RWMutex
|
||||||
|
yamlData []byte
|
||||||
|
loaded *graph.Graph
|
||||||
|
collector *collector.Collector
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGraphHandlers loads the graph from the given YAML file path.
|
||||||
|
func NewGraphHandlers(yamlPath string, coll *collector.Collector) *GraphHandlers {
|
||||||
|
h := &GraphHandlers{path: yamlPath, collector: coll}
|
||||||
|
if err := h.reload(); err != nil {
|
||||||
|
if _, ok := err.(*os.PathError); ok {
|
||||||
|
log.Printf("[graph] no graph file at %q, starting with empty graph", yamlPath)
|
||||||
|
h.loaded = graph.New()
|
||||||
|
h.yamlData = []byte("nodes: {}\n")
|
||||||
|
} else {
|
||||||
|
log.Fatalf("[graph] failed to load graph from %q: %v", yamlPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *GraphHandlers) reload() error {
|
||||||
|
data, err := os.ReadFile(h.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g, err := graph.ParseYAML(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.yamlData = data
|
||||||
|
h.loaded = g
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadedGraph returns the current parsed graph.
|
||||||
|
func (h *GraphHandlers) LoadedGraph() *graph.Graph {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return h.loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGraph returns the current dependency graph as JSON.
|
||||||
|
// @Summary Get dependency graph
|
||||||
|
// @Description Returns the service dependency graph as JSON
|
||||||
|
// @Tags graph
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]interface{} "Dependency graph"
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /graph [get]
|
||||||
|
func (h *GraphHandlers) GetGraph(c *gin.Context) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
g := h.loaded
|
||||||
|
if g == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"nodes": map[string]interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := make(map[string]interface{})
|
||||||
|
for _, node := range g.Nodes() {
|
||||||
|
services := make(map[string]interface{})
|
||||||
|
for _, svc := range node.Services {
|
||||||
|
deps := make([]map[string]interface{}, 0)
|
||||||
|
for _, dep := range svc.Dependencies {
|
||||||
|
deps = append(deps, map[string]interface{}{
|
||||||
|
"target": dep.Target,
|
||||||
|
"condition": dep.Condition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
services[svc.Name] = map[string]interface{}{
|
||||||
|
"dependencies": deps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes[node.ID] = map[string]interface{}{
|
||||||
|
"services": services,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"nodes": nodes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateYAML updates the graph from new YAML text.
|
||||||
|
// @Summary Update dependency graph YAML
|
||||||
|
// @Description Replaces the service dependency graph YAML and reloads it
|
||||||
|
// @Tags graph
|
||||||
|
// @Accept plain
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body string true "New YAML content"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /graph [put]
|
||||||
|
func (h *GraphHandlers) UpdateYAML(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := graph.ParseYAML(body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(h.path, body, 0o644); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write graph file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.yamlData = body
|
||||||
|
h.loaded = g
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[graph] updated graph from admin, saved to %s", h.path)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "graph updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartupOrder returns the computed service startup order.
|
||||||
|
// @Summary Get startup order
|
||||||
|
// @Description Returns the topologically sorted service startup order
|
||||||
|
// @Tags graph
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /graph/order [get]
|
||||||
|
func (h *GraphHandlers) StartupOrder(c *gin.Context) {
|
||||||
|
h.mu.RLock()
|
||||||
|
g := h.loaded
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
order, err := g.TopologicalSort()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CycleCheck checks if the graph has cycles.
|
||||||
|
// @Summary Check for cycles
|
||||||
|
// @Description Returns whether the dependency graph contains cycles
|
||||||
|
// @Tags graph
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]bool
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /graph/cycle [get]
|
||||||
|
func (h *GraphHandlers) CycleCheck(c *gin.Context) {
|
||||||
|
h.mu.RLock()
|
||||||
|
g := h.loaded
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"has_cycle": g.HasCycle()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceStatusOut represents a service and its current status.
|
||||||
|
type ServiceStatusOut struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailureRootCauseOut represents the result of a failure analysis.
|
||||||
|
type FailureRootCauseOut struct {
|
||||||
|
Affected ServiceStatusOut `json:"affected"`
|
||||||
|
RootCause *ServiceStatusOut `json:"root_cause,omitempty"`
|
||||||
|
DependencyChain []string `json:"dependency_chain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFailureRootCause analyzes the dependency graph and current service
|
||||||
|
// statuses to find the root cause of a service failure.
|
||||||
|
// If the specified service is unhealthy, it traverses its dependencies
|
||||||
|
// to find the first unhealthy dependency — the one that is the root cause.
|
||||||
|
// @Summary Find failure root cause
|
||||||
|
// @Description Analyzes dependencies and service statuses to find the root cause of a failure
|
||||||
|
// @Tags graph
|
||||||
|
// @Param node_id query string false "Node ID (agent label)"
|
||||||
|
// @Param service query string true "Service name"
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} FailureRootCauseOut
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /graph/failure [get]
|
||||||
|
func (h *GraphHandlers) GetFailureRootCause(c *gin.Context) {
|
||||||
|
nodeID := c.Query("node_id")
|
||||||
|
svcName := c.Query("service")
|
||||||
|
if svcName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "service query param is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
g := h.loaded
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if g == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no graph loaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of service statuses from all agents
|
||||||
|
svcStatus := h.buildServiceStatusMap()
|
||||||
|
|
||||||
|
// If no node specified, search all nodes for the service
|
||||||
|
if nodeID == "" {
|
||||||
|
for _, node := range g.Nodes() {
|
||||||
|
if _, ok := g.GetService(node.ID, svcName); ok {
|
||||||
|
nodeID = node.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodeID == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "service not found in graph"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := g.GetService(nodeID, svcName); !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "service not found in node"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current status
|
||||||
|
status := svcStatus[nodeID+":"+svcName]
|
||||||
|
affected := ServiceStatusOut{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Name: svcName,
|
||||||
|
Status: status.status,
|
||||||
|
Healthy: status.healthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the service is healthy, no failure to analyze
|
||||||
|
if status.healthy {
|
||||||
|
c.JSON(http.StatusOK, FailureRootCauseOut{
|
||||||
|
Affected: affected,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root cause: traverse dependencies to find the first unhealthy one
|
||||||
|
rootCause, chain := findRootCause(g, nodeID, svcName, svcStatus)
|
||||||
|
|
||||||
|
out := FailureRootCauseOut{
|
||||||
|
Affected: affected,
|
||||||
|
DependencyChain: chain,
|
||||||
|
}
|
||||||
|
if rootCause != nil {
|
||||||
|
out.RootCause = rootCause
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// svcStatusEntry holds parsed status info.
|
||||||
|
type svcStatusEntry struct {
|
||||||
|
status string
|
||||||
|
healthy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServiceStatusMap creates a map of "nodeID:serviceName" → status.
|
||||||
|
// Matches graph nodes to agent labels in the collector.
|
||||||
|
func (h *GraphHandlers) buildServiceStatusMap() map[string]svcStatusEntry {
|
||||||
|
result := make(map[string]svcStatusEntry)
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
nodes := h.loaded.Nodes()
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, agent := range h.collector.Agents() {
|
||||||
|
for _, svc := range agent.Services {
|
||||||
|
healthy := isHealthyStatus(svc.Status)
|
||||||
|
entry := svcStatusEntry{status: svc.Status, healthy: healthy}
|
||||||
|
|
||||||
|
// Try exact node match first
|
||||||
|
key := agent.Label + ":" + svc.Name
|
||||||
|
result[key] = entry
|
||||||
|
|
||||||
|
// Also register under all nodes that don't have a status yet
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeKey := node.ID + ":" + svc.Name
|
||||||
|
if _, exists := result[nodeKey]; !exists {
|
||||||
|
result[nodeKey] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRootCause traverses the dependency graph to find the first unhealthy dependency.
|
||||||
|
func findRootCause(g *graph.Graph, nodeID, svcName string, statusMap map[string]svcStatusEntry) (*ServiceStatusOut, []string) {
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
var chain []string
|
||||||
|
|
||||||
|
var dfs func(string, string) *ServiceStatusOut
|
||||||
|
dfs = func(nid, sname string) *ServiceStatusOut {
|
||||||
|
key := nid + ":" + sname
|
||||||
|
chain = append(chain, key)
|
||||||
|
visited[key] = true
|
||||||
|
|
||||||
|
svc, ok := g.GetService(nid, sname)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each dependency
|
||||||
|
for _, dep := range svc.Dependencies {
|
||||||
|
depNodeID := dep.Target.NodeID
|
||||||
|
if depNodeID == "" {
|
||||||
|
depNodeID = nid
|
||||||
|
}
|
||||||
|
depKey := depNodeID + ":" + dep.Target.Name
|
||||||
|
|
||||||
|
if visited[depKey] {
|
||||||
|
continue // avoid loops
|
||||||
|
}
|
||||||
|
|
||||||
|
depStatus := statusMap[depKey]
|
||||||
|
|
||||||
|
if !depStatus.healthy {
|
||||||
|
// This dependency is unhealthy — check if IT has an unhealthy dependency
|
||||||
|
// (to find the true root cause)
|
||||||
|
if deeper := dfs(depNodeID, dep.Target.Name); deeper != nil {
|
||||||
|
return deeper
|
||||||
|
}
|
||||||
|
// This is the root cause
|
||||||
|
return &ServiceStatusOut{
|
||||||
|
NodeID: depNodeID,
|
||||||
|
Name: dep.Target.Name,
|
||||||
|
Status: depStatus.status,
|
||||||
|
Healthy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root := dfs(nodeID, svcName)
|
||||||
|
|
||||||
|
// Deduplicate chain
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var deduped []string
|
||||||
|
for _, k := range chain {
|
||||||
|
if !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
deduped = append(deduped, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root, deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHealthyStatus(status string) bool {
|
||||||
|
s := strings.ToLower(status)
|
||||||
|
return s == "running" || s == "up" || s == "healthy"
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobsHandlers struct {
|
||||||
|
tracker *commander.ConnTracker
|
||||||
|
svc *service.ScriptService
|
||||||
|
whereami string
|
||||||
|
jobRepo *repository.JobRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobsHandlers(tracker *commander.ConnTracker, svc *service.ScriptService, whereami string, jobRepo *repository.JobRepository) JobsHandlers {
|
||||||
|
return JobsHandlers{tracker: tracker, svc: svc, whereami: whereami, jobRepo: jobRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddJobIn is the request body for creating a job.
|
||||||
|
type AddJobIn struct {
|
||||||
|
Command string `json:"command" binding:"required"`
|
||||||
|
InterpreterID int64 `json:"interpreter_id"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
AgentID string `json:"agent_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddJobOut is the response body for a submitted job.
|
||||||
|
type AddJobOut struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Command []string `json:"command"`
|
||||||
|
WaitURL string `json:"wait_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobResult is the response body for a completed job.
|
||||||
|
type JobResult struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddJob submits a job to an agent and returns a wait_url for the result.
|
||||||
|
// @Summary Submit a job to an agent
|
||||||
|
// @Description Sends a command to the specified agent and returns a URL to wait for the result
|
||||||
|
// @Tags jobs
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body AddJobIn true "Job request"
|
||||||
|
// @Success 201 {object} AddJobOut
|
||||||
|
// @Router /jobs [post]
|
||||||
|
func (h *JobsHandlers) AddJob(c *gin.Context) {
|
||||||
|
var in AddJobIn
|
||||||
|
if err := c.Bind(&in); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.runCommand(c, in.AgentID, in.InterpreterID, in.Command, in.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommand resolves command, submits a job to the agent, and returns AddJobOut.
|
||||||
|
// Shared between jobs and scripts handlers.
|
||||||
|
func (h *JobsHandlers) runCommand(
|
||||||
|
c *gin.Context,
|
||||||
|
agentID string,
|
||||||
|
interpID int64,
|
||||||
|
command string,
|
||||||
|
stdin *string,
|
||||||
|
) (*AddJobOut, error) {
|
||||||
|
agent, ok := h.tracker.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("agent not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := resolveCommand(c, h.svc, interpID, command)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jid, err := agent.AddJob(models.JobForInsert{
|
||||||
|
Command: cmd,
|
||||||
|
Stdin: stdin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", h.whereami, jid)
|
||||||
|
|
||||||
|
return &AddJobOut{
|
||||||
|
ID: jid,
|
||||||
|
Command: cmd,
|
||||||
|
WaitURL: waitURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitJob waits for a submitted job to complete (long-poll).
|
||||||
|
// First checks the database; if already finished, returns immediately.
|
||||||
|
// Otherwise waits on the agent for the result.
|
||||||
|
// @Summary Wait for job result
|
||||||
|
// @Description Long-polls for a job result. Returns immediately if the job is already finished.
|
||||||
|
// @Tags jobs
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Job ID"
|
||||||
|
// @Success 200 {object} JobResult
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Router /jobs/{id}/wait [post]
|
||||||
|
func (h *JobsHandlers) WaitJob(c *gin.Context) {
|
||||||
|
jid, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database first
|
||||||
|
job, err := h.jobRepo.GetJobByID(c.Request.Context(), jid)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If job is already completed (has output or non-zero status), return immediately
|
||||||
|
if job.Status != nil || job.Stdout != nil || job.Stderr != nil {
|
||||||
|
c.JSON(http.StatusOK, JobResult{
|
||||||
|
ID: job.ID,
|
||||||
|
Command: job.Command,
|
||||||
|
Stdin: job.Stdin,
|
||||||
|
Stdout: *job.Stdout,
|
||||||
|
Stderr: *job.Stderr,
|
||||||
|
Status: *job.Status,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is still pending — wait on the agent
|
||||||
|
agent, ok := h.tracker.GetAgent(job.AgentID)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ajob, err := agent.WaitJob(jid)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, JobResult{
|
||||||
|
ID: ajob.ID,
|
||||||
|
Command: ajob.Command,
|
||||||
|
Stdin: ajob.Stdin,
|
||||||
|
Stdout: *ajob.Stdout,
|
||||||
|
Stderr: *ajob.Stderr,
|
||||||
|
Status: *ajob.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCommand(c *gin.Context, svc *service.ScriptService, interpID int64, cmd string) ([]string, error) {
|
||||||
|
if interpID == 0 {
|
||||||
|
return []string{"sh", "-c", cmd}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := svc.ResolveCommand(c.Request.Context(), interpID, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobMetricsOut is the response body for the job metrics endpoint.
|
||||||
|
type JobMetricsOut struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobMetrics returns job success metrics over a parameterized period.
|
||||||
|
// @Summary Get job metrics
|
||||||
|
// @Description Returns total, successful, failed, and pending job counts over the given period
|
||||||
|
// @Tags jobs
|
||||||
|
// @Produce json
|
||||||
|
// @Param period query string false "Time period (e.g. 1h, 24h, 7d)" default(24h)
|
||||||
|
// @Param agent_id query string false "Filter by agent ID"
|
||||||
|
// @Success 200 {object} JobMetricsOut
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /jobs/metrics [get]
|
||||||
|
func (h *JobsHandlers) GetJobMetrics(c *gin.Context) {
|
||||||
|
periodStr := c.DefaultQuery("period", "24h")
|
||||||
|
period, err := time.ParseDuration(periodStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid period, use Go duration format (e.g. 1h, 24h, 7d)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agentID := c.Query("agent_id")
|
||||||
|
since := time.Now().Add(-period)
|
||||||
|
metrics, err := h.jobRepo.GetJobMetrics(c.Request.Context(), since, agentID)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, JobMetricsOut{
|
||||||
|
Total: metrics.Total,
|
||||||
|
Success: metrics.Success,
|
||||||
|
Failed: metrics.Failed,
|
||||||
|
Pending: metrics.Pending,
|
||||||
|
Period: periodStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ type InsertLogRequest struct {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body InsertLogRequest true "Log entry"
|
// @Param body body InsertLogRequest true "Log entry"
|
||||||
// @Success 201 {object} map[string]string
|
// @Success 201 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /logs [post]
|
// @Router /logs [post]
|
||||||
func (lh *LogHandlers) Insert(c *gin.Context) {
|
func (lh *LogHandlers) Insert(c *gin.Context) {
|
||||||
var req InsertLogRequest
|
var req InsertLogRequest
|
||||||
@@ -72,6 +73,7 @@ type InsertLogsRequest struct {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body InsertLogsRequest true "Log entries"
|
// @Param body body InsertLogsRequest true "Log entries"
|
||||||
// @Success 201 {object} map[string]string
|
// @Success 201 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /logs/batch [post]
|
// @Router /logs/batch [post]
|
||||||
func (lh *LogHandlers) InsertBatch(c *gin.Context) {
|
func (lh *LogHandlers) InsertBatch(c *gin.Context) {
|
||||||
var req InsertLogsRequest
|
var req InsertLogsRequest
|
||||||
@@ -124,6 +126,7 @@ type SearchLogsRequest struct {
|
|||||||
// @Param limit query int false "Limit results" default(100)
|
// @Param limit query int false "Limit results" default(100)
|
||||||
// @Param offset query int false "Offset results" default(0)
|
// @Param offset query int false "Offset results" default(0)
|
||||||
// @Success 200 {array} storage.LogEntry
|
// @Success 200 {array} storage.LogEntry
|
||||||
|
// @Security Bearer
|
||||||
// @Router /logs [get]
|
// @Router /logs [get]
|
||||||
func (lh *LogHandlers) Search(c *gin.Context) {
|
func (lh *LogHandlers) Search(c *gin.Context) {
|
||||||
var req SearchLogsRequest
|
var req SearchLogsRequest
|
||||||
@@ -170,6 +173,7 @@ func (lh *LogHandlers) Search(c *gin.Context) {
|
|||||||
// @Tags logs
|
// @Tags logs
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /logs/services [get]
|
// @Router /logs/services [get]
|
||||||
func (lh *LogHandlers) GetServices(c *gin.Context) {
|
func (lh *LogHandlers) GetServices(c *gin.Context) {
|
||||||
services, err := lh.LogRepo.GetDistinctServices(c.Request.Context())
|
services, err := lh.LogRepo.GetDistinctServices(c.Request.Context())
|
||||||
@@ -190,6 +194,7 @@ func (lh *LogHandlers) GetServices(c *gin.Context) {
|
|||||||
// @Tags logs
|
// @Tags logs
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /logs/agents [get]
|
// @Router /logs/agents [get]
|
||||||
func (lh *LogHandlers) GetAgents(c *gin.Context) {
|
func (lh *LogHandlers) GetAgents(c *gin.Context) {
|
||||||
agents, err := lh.LogRepo.GetDistinctAgents(c.Request.Context())
|
agents, err := lh.LogRepo.GetDistinctAgents(c.Request.Context())
|
||||||
@@ -210,6 +215,7 @@ func (lh *LogHandlers) GetAgents(c *gin.Context) {
|
|||||||
// @Tags logs
|
// @Tags logs
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
|
// @Security Bearer
|
||||||
// @Router /logs/levels [get]
|
// @Router /logs/levels [get]
|
||||||
func (lh *LogHandlers) GetLevels(c *gin.Context) {
|
func (lh *LogHandlers) GetLevels(c *gin.Context) {
|
||||||
levels, err := lh.LogRepo.GetDistinctLevels(c.Request.Context())
|
levels, err := lh.LogRepo.GetDistinctLevels(c.Request.Context())
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMockLogs returns 100 mock log entries for frontend development
|
||||||
|
// @Summary Get mock logs
|
||||||
|
// @Description Returns 100 mock log entries for frontend development (no ClickHouse required)
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce json
|
||||||
|
// @Param level query string false "Filter by level"
|
||||||
|
// @Param service query string false "Filter by service"
|
||||||
|
// @Param agent query string false "Filter by agent"
|
||||||
|
// @Param limit query int false "Limit results" default(100)
|
||||||
|
// @Param offset query int false "Offset results" default(0)
|
||||||
|
// @Success 200 {array} storage.LogEntry
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs/mock [get]
|
||||||
|
func (lh *LogHandlers) GetMockLogs(c *gin.Context) {
|
||||||
|
levelFilter := c.Query("level")
|
||||||
|
serviceFilter := c.Query("service")
|
||||||
|
agentFilter := c.Query("agent")
|
||||||
|
|
||||||
|
limit := 100
|
||||||
|
offset := 0
|
||||||
|
|
||||||
|
if l := c.Query("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o := c.Query("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := generateMockLogs(100)
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
var filtered []storage.LogEntry
|
||||||
|
for _, log := range logs {
|
||||||
|
if levelFilter != "" && log.Level != levelFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if serviceFilter != "" && log.Service != serviceFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if agentFilter != "" && log.Agent != agentFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filtered) {
|
||||||
|
end = len(filtered)
|
||||||
|
}
|
||||||
|
if offset > len(filtered) {
|
||||||
|
filtered = []storage.LogEntry{}
|
||||||
|
} else {
|
||||||
|
filtered = filtered[offset:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateMockLogs(count int) []storage.LogEntry {
|
||||||
|
services := []string{
|
||||||
|
"auth-service",
|
||||||
|
"user-service",
|
||||||
|
"agent-service",
|
||||||
|
"gateway",
|
||||||
|
"scheduler",
|
||||||
|
"notification-service",
|
||||||
|
"metrics-collector",
|
||||||
|
"deployment-service",
|
||||||
|
}
|
||||||
|
|
||||||
|
agents := []string{
|
||||||
|
"agent-prod-01",
|
||||||
|
"agent-prod-02",
|
||||||
|
"agent-staging-01",
|
||||||
|
"agent-dev-01",
|
||||||
|
"agent-dev-02",
|
||||||
|
"agent-monitoring-01",
|
||||||
|
"agent-backup-01",
|
||||||
|
"agent-ci-runner-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
levels := []string{"INFO", "WARNING", "ERROR", "FATAL", "DEBUG"}
|
||||||
|
levelWeights := []int{50, 20, 15, 5, 10} // weighted distribution
|
||||||
|
|
||||||
|
messages := map[string][]string{
|
||||||
|
"INFO": {
|
||||||
|
"Service started successfully",
|
||||||
|
"Health check passed",
|
||||||
|
"Configuration loaded",
|
||||||
|
"Connection established to database",
|
||||||
|
"Cache refreshed successfully",
|
||||||
|
"Request processed in 45ms",
|
||||||
|
"User login successful",
|
||||||
|
"Agent registered successfully",
|
||||||
|
"Deployment completed for 3 servers",
|
||||||
|
"Metrics exported to storage",
|
||||||
|
"Backup completed successfully",
|
||||||
|
"SSL certificate valid for 89 days",
|
||||||
|
"Task scheduled: cleanup-temp-files",
|
||||||
|
"Webhook delivered successfully",
|
||||||
|
"Session created for user admin",
|
||||||
|
},
|
||||||
|
"WARNING": {
|
||||||
|
"High memory usage detected: 85%",
|
||||||
|
"Slow query detected: 2.3s",
|
||||||
|
"Rate limit approaching for client 192.168.1.50",
|
||||||
|
"Certificate expires in 7 days",
|
||||||
|
"Retry attempt 2/3 for request",
|
||||||
|
"Disk usage above threshold: 78%",
|
||||||
|
"Connection pool nearly exhausted: 45/50",
|
||||||
|
"Deprecated API endpoint called: /api/v1/legacy",
|
||||||
|
"Response time exceeded SLA: 1.2s > 1s",
|
||||||
|
"Agent heartbeat delayed by 5s",
|
||||||
|
},
|
||||||
|
"ERROR": {
|
||||||
|
"Failed to connect to database: timeout after 30s",
|
||||||
|
"Authentication failed for user test_user",
|
||||||
|
"Agent deployment failed: SSH connection refused",
|
||||||
|
"Failed to send notification: SMTP server unavailable",
|
||||||
|
"Request failed with status 500",
|
||||||
|
"File not found: /etc/hellreign/config.yml",
|
||||||
|
"Invalid token provided",
|
||||||
|
"Permission denied for user viewer",
|
||||||
|
"Failed to parse configuration: invalid YAML",
|
||||||
|
"Agent unreachable: connection timeout",
|
||||||
|
},
|
||||||
|
"FATAL": {
|
||||||
|
"Out of memory: cannot allocate 512MB",
|
||||||
|
"Database connection lost, all retries exhausted",
|
||||||
|
"Critical: SSL certificate expired",
|
||||||
|
"Unrecoverable error: data corruption detected",
|
||||||
|
"Service crashed: segmentation fault",
|
||||||
|
},
|
||||||
|
"DEBUG": {
|
||||||
|
"Processing request payload: 2.3KB",
|
||||||
|
"Cache hit ratio: 78%",
|
||||||
|
"Executing query: SELECT * FROM logs WHERE...",
|
||||||
|
"HTTP request headers: {Content-Type: application/json}",
|
||||||
|
"Agent status check: 8 agents online",
|
||||||
|
"Memory allocation: 256MB used of 1024MB",
|
||||||
|
"Thread pool size: 12 active, 4 idle",
|
||||||
|
"GC pause: 15ms",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := rand.New(rand.NewSource(42)) // fixed seed for reproducibility
|
||||||
|
|
||||||
|
var logs []storage.LogEntry
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
level := weightedRandom(r, levels, levelWeights)
|
||||||
|
service := services[r.Intn(len(services))]
|
||||||
|
agent := agents[r.Intn(len(agents))]
|
||||||
|
msgs := messages[level]
|
||||||
|
message := msgs[r.Intn(len(msgs))]
|
||||||
|
|
||||||
|
// Spread logs over the last 24 hours
|
||||||
|
timestamp := now.Add(-time.Duration(count-i) * time.Minute * 15)
|
||||||
|
|
||||||
|
logs = append(logs, storage.LogEntry{
|
||||||
|
Timestamp: timestamp,
|
||||||
|
Level: level,
|
||||||
|
Service: service,
|
||||||
|
Agent: agent,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedRandom(r *rand.Rand, items []string, weights []int) string {
|
||||||
|
total := 0
|
||||||
|
for _, w := range weights {
|
||||||
|
total += w
|
||||||
|
}
|
||||||
|
n := r.Intn(total)
|
||||||
|
for i, w := range weights {
|
||||||
|
n -= w
|
||||||
|
if n < 0 {
|
||||||
|
return items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items[len(items)-1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptHandlers struct {
|
||||||
|
svc *service.ScriptService
|
||||||
|
tracker *commander.ConnTracker
|
||||||
|
whereami string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptHandlers(svc *service.ScriptService, tracker *commander.ConnTracker, whereami string) ScriptHandlers {
|
||||||
|
return ScriptHandlers{svc: svc, tracker: tracker, whereami: whereami}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunScriptIn struct {
|
||||||
|
AgentID string `json:"agent_id" binding:"required"`
|
||||||
|
InterpreterID int64 `json:"interpreter_id" binding:"required"`
|
||||||
|
ScriptText string `json:"script_text" binding:"required"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScript submits a script as a job and returns a wait_url for the result.
|
||||||
|
// @Summary Run a script on an agent
|
||||||
|
// @Description Resolves interpreter argv[] and sends the full command to the agent
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body RunScriptIn true "Script request"
|
||||||
|
// @Success 201 {object} AddJobOut
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/run [post]
|
||||||
|
func (h *ScriptHandlers) RunScript(c *gin.Context) {
|
||||||
|
var in RunScriptIn
|
||||||
|
if err := c.Bind(&in); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, ok := h.tracker.GetAgent(in.AgentID)
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
c.Error(fmt.Errorf("agent not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := h.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jid, err := agent.AddJob(models.JobForInsert{
|
||||||
|
Command: command,
|
||||||
|
Stdin: in.Stdin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", h.whereami, jid)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, AddJobOut{
|
||||||
|
ID: jid,
|
||||||
|
Command: command,
|
||||||
|
WaitURL: waitURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInterpreters returns all registered script interpreters.
|
||||||
|
// @Summary List interpreters
|
||||||
|
// @Description Returns all script interpreters available in the system
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} repository.ScriptInterpreter
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/interpreters [get]
|
||||||
|
func (h *ScriptHandlers) ListInterpreters(c *gin.Context) {
|
||||||
|
interpreters, err := h.svc.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, interpreters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInterpreter registers a new script interpreter.
|
||||||
|
// @Summary Create interpreter
|
||||||
|
// @Description Registers a new script interpreter with name, label, and argv
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body repository.ScriptInterpreterCreate true "Interpreter definition"
|
||||||
|
// @Success 201 {object} repository.ScriptInterpreter
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/interpreters [post]
|
||||||
|
func (h *ScriptHandlers) CreateInterpreter(c *gin.Context) {
|
||||||
|
var in repository.ScriptInterpreterCreate
|
||||||
|
if err := c.BindJSON(&in); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
si, err := h.svc.Create(c.Request.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, si)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInterpreter returns a single interpreter by ID.
|
||||||
|
// @Summary Get interpreter
|
||||||
|
// @Description Returns a script interpreter by ID
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Interpreter ID"
|
||||||
|
// @Success 200 {object} repository.ScriptInterpreter
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/interpreters/:id [get]
|
||||||
|
func (h *ScriptHandlers) GetInterpreter(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
si, err := h.svc.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, si)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInterpreter updates an interpreter.
|
||||||
|
// @Summary Update interpreter
|
||||||
|
// @Description Updates fields of a script interpreter
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Interpreter ID"
|
||||||
|
// @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields"
|
||||||
|
// @Success 200 {object} repository.ScriptInterpreter
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/interpreters/:id [put]
|
||||||
|
func (h *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in repository.ScriptInterpreterUpdate
|
||||||
|
if err := c.BindJSON(&in); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
si, err := h.svc.Update(c.Request.Context(), id, in)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, si)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInterpreter removes an interpreter.
|
||||||
|
// @Summary Delete interpreter
|
||||||
|
// @Description Removes a script interpreter by ID
|
||||||
|
// @Tags scripts
|
||||||
|
// @Param id path int true "Interpreter ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/interpreters/:id [delete]
|
||||||
|
func (h *ScriptHandlers) DeleteInterpreter(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScriptHandlersGroup handles script management routes.
|
||||||
|
type ScriptHandlersGroup struct {
|
||||||
|
svc *service.ScriptService
|
||||||
|
cmder *commander.Commander
|
||||||
|
whereami string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScriptHandlersGroup creates a new ScriptHandlersGroup.
|
||||||
|
func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander, whereami string) *ScriptHandlersGroup {
|
||||||
|
return &ScriptHandlersGroup{svc: svc, cmder: cmder, whereami: whereami}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTree returns the script directory tree.
|
||||||
|
// @Summary Get script directory tree
|
||||||
|
// @Description Returns a hierarchical tree of all scripts organized by their paths
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} repository.ScriptTreeNode
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/tree [get]
|
||||||
|
func (sh *ScriptHandlersGroup) GetTree(c *gin.Context) {
|
||||||
|
tree, err := sh.svc.BuildTree()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build script tree"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tree == nil {
|
||||||
|
tree = []repository.ScriptTreeNode{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateScript creates a new script.
|
||||||
|
// @Summary Create script
|
||||||
|
// @Description Creates a new script with path, content, and interpreter binding
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body repository.ScriptCreate true "Script data"
|
||||||
|
// @Success 201 {object} repository.Script
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts [post]
|
||||||
|
func (sh *ScriptHandlersGroup) CreateScript(c *gin.Context) {
|
||||||
|
var req repository.ScriptCreate
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path
|
||||||
|
if err := validateScriptPath(req.Path); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := sh.svc.Repo.CreateScript(req)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueConstraint(err) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "script with this path already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create script"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScript returns a script by ID.
|
||||||
|
// @Summary Get script
|
||||||
|
// @Description Returns a script by its ID
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Script ID"
|
||||||
|
// @Success 200 {object} repository.Script
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/:id [get]
|
||||||
|
func (sh *ScriptHandlersGroup) GetScript(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := sh.svc.Repo.GetScript(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateScript updates a script.
|
||||||
|
// @Summary Update script
|
||||||
|
// @Description Updates a script's path, content, or interpreter
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Script ID"
|
||||||
|
// @Param body body repository.ScriptUpdate true "Script data"
|
||||||
|
// @Success 200 {object} repository.Script
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/:id [put]
|
||||||
|
func (sh *ScriptHandlersGroup) UpdateScript(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req repository.ScriptUpdate
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path if it's being updated
|
||||||
|
if req.Path != nil {
|
||||||
|
if err := validateScriptPath(*req.Path); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := sh.svc.Repo.UpdateScript(id, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update script"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteScript deletes a script.
|
||||||
|
// @Summary Delete script
|
||||||
|
// @Description Deletes a script by its ID
|
||||||
|
// @Tags scripts
|
||||||
|
// @Param id path int true "Script ID"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/:id [delete]
|
||||||
|
func (sh *ScriptHandlersGroup) DeleteScript(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sh.svc.Repo.DeleteScript(id); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete script"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "script deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScriptByID executes a stored script on a target agent.
|
||||||
|
// @Summary Run script by ID
|
||||||
|
// @Description Loads a script from storage, resolves interpreter command, and submits it to the agent
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Script ID"
|
||||||
|
// @Param body body RunStoredScriptIn true "Agent ID and optional stdin"
|
||||||
|
// @Success 201 {object} AddJobOut
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/:id/run [post]
|
||||||
|
func (sh *ScriptHandlersGroup) RunScriptByID(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in RunStoredScriptIn
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := sh.svc.Repo.GetScript(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := sh.svc.ResolveCommand(c.Request.Context(), script.InterpreterID, script.Content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve command: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, ok := sh.cmder.GetAgent(in.Token)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jid, err := agent.AddJob(models.JobForInsert{
|
||||||
|
Command: command,
|
||||||
|
Stdin: in.Stdin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add job: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", sh.whereami, jid)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, AddJobOut{
|
||||||
|
ID: jid,
|
||||||
|
Command: command,
|
||||||
|
WaitURL: waitURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStoredScriptIn is the request body for running a stored script on an agent.
|
||||||
|
type RunStoredScriptIn struct {
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolderRequest is the request body for creating a script folder.
|
||||||
|
type CreateFolderRequest struct {
|
||||||
|
Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path (e.g. 'deploy/nginx')"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolderRequest is the request body for deleting a script folder.
|
||||||
|
type DeleteFolderRequest struct {
|
||||||
|
Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path to delete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameRequest is the request body for renaming a script or folder.
|
||||||
|
type RenameRequest struct {
|
||||||
|
OldPath string `json:"old_path" binding:"required" example:"deploy/nginx" description:"Current path"`
|
||||||
|
NewPath string `json:"new_path" binding:"required" example:"deploy/nginx-v2" description:"New path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename renames a script or all scripts under a folder path.
|
||||||
|
// @Summary Rename script or folder
|
||||||
|
// @Description Renames a single script or all scripts under a folder prefix
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body RenameRequest true "Rename request"
|
||||||
|
// @Success 200 {object} map[string]interface{} "Rename result with count of renamed scripts"
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 409 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/rename [post]
|
||||||
|
func (sh *ScriptHandlersGroup) Rename(c *gin.Context) {
|
||||||
|
var req RenameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new path
|
||||||
|
if err := validateScriptPath(req.NewPath); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid new path: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate old path
|
||||||
|
if err := validateScriptPath(req.OldPath); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid old path: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all scripts
|
||||||
|
allScripts, err := sh.svc.Repo.ListScripts()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find scripts to rename: exact match or folder prefix
|
||||||
|
prefix := req.OldPath + "/"
|
||||||
|
var toRename []repository.Script
|
||||||
|
for _, script := range allScripts {
|
||||||
|
if script.Path == req.OldPath || strings.HasPrefix(script.Path, prefix) {
|
||||||
|
toRename = append(toRename, script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toRename) == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no scripts found with this path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename each script
|
||||||
|
renamedCount := 0
|
||||||
|
for _, script := range toRename {
|
||||||
|
newPath := req.NewPath + strings.TrimPrefix(script.Path, req.OldPath)
|
||||||
|
|
||||||
|
// Check if new path already exists (excluding the scripts we're renaming)
|
||||||
|
for _, existing := range allScripts {
|
||||||
|
if existing.ID != script.ID && existing.Path == newPath {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("path '%s' already exists", newPath)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := sh.svc.Repo.UpdateScript(script.ID, repository.ScriptUpdate{
|
||||||
|
Path: &newPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to rename %s: %v", script.Path, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renamedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "renamed",
|
||||||
|
"old_path": req.OldPath,
|
||||||
|
"new_path": req.NewPath,
|
||||||
|
"renamed_count": renamedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder creates a virtual folder in the script tree.
|
||||||
|
// @Summary Create folder
|
||||||
|
// @Description Creates a virtual folder by creating a placeholder script with the folder path
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body CreateFolderRequest true "Folder path"
|
||||||
|
// @Success 201 {object} map[string]string "Folder created"
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/folder [post]
|
||||||
|
func (sh *ScriptHandlersGroup) CreateFolder(c *gin.Context) {
|
||||||
|
var req CreateFolderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate folder path
|
||||||
|
if err := validateScriptPath(req.Path); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a placeholder script with the folder path to ensure the folder exists in the tree
|
||||||
|
// The placeholder uses ".folder" as content and interpreter_id 0 (will be resolved at runtime)
|
||||||
|
_, err := sh.svc.Repo.CreateScript(repository.ScriptCreate{
|
||||||
|
Path: req.Path,
|
||||||
|
Content: "",
|
||||||
|
InterpreterID: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueConstraint(err) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "folder with this path already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"message": "folder created", "path": req.Path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder deletes all scripts under a given path prefix.
|
||||||
|
// @Summary Delete folder
|
||||||
|
// @Description Deletes all scripts that start with the given folder path
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body DeleteFolderRequest true "Folder path"
|
||||||
|
// @Success 200 {object} map[string]interface{} "Folder deleted with count of deleted scripts"
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/folder [delete]
|
||||||
|
func (sh *ScriptHandlersGroup) DeleteFolder(c *gin.Context) {
|
||||||
|
var req DeleteFolderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate folder path
|
||||||
|
if err := validateScriptPath(req.Path); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all scripts and filter by path prefix
|
||||||
|
allScripts, err := sh.svc.Repo.ListScripts()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := req.Path + "/"
|
||||||
|
deletedCount := 0
|
||||||
|
for _, script := range allScripts {
|
||||||
|
// Delete scripts that are in this folder (path starts with prefix)
|
||||||
|
// or the folder placeholder itself (exact match)
|
||||||
|
if script.Path == req.Path || strings.HasPrefix(script.Path, prefix) {
|
||||||
|
if err := sh.svc.Repo.DeleteScript(script.ID); err != nil && !errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete script %s: %v", script.Path, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "folder deleted", "path": req.Path, "deleted_count": deletedCount})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScriptByPath returns a script by its path.
|
||||||
|
// @Summary Get script by path
|
||||||
|
// @Description Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Param path query string true "Script path"
|
||||||
|
// @Success 200 {object} repository.Script
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /scripts/by-path [get]
|
||||||
|
func (sh *ScriptHandlersGroup) GetScriptByPath(c *gin.Context) {
|
||||||
|
path := c.Query("path")
|
||||||
|
if path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := sh.svc.Repo.GetScriptByPath(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUniqueConstraint checks if the error is a SQLite UNIQUE constraint violation.
|
||||||
|
func isUniqueConstraint(err error) bool {
|
||||||
|
return err != nil && (err.Error() != "" && contains(err.Error(), "UNIQUE constraint"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && searchSubstring(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchSubstring(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateScriptPath validates that a script path is well-formed.
|
||||||
|
// Rules: non-empty, no leading slash, no double slashes, no trailing slash, no empty segments.
|
||||||
|
func validateScriptPath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("path cannot be empty")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/") {
|
||||||
|
return fmt.Errorf("path cannot start with '/'")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/") {
|
||||||
|
return fmt.Errorf("path cannot end with '/'")
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "//") {
|
||||||
|
return fmt.Errorf("path cannot contain '//'")
|
||||||
|
}
|
||||||
|
// Check for empty segments (e.g. "a//b" already caught, but "a/ /b" should be allowed)
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
for i, seg := range segments {
|
||||||
|
if strings.TrimSpace(seg) == "" {
|
||||||
|
return fmt.Errorf("path segment %d cannot be empty or whitespace", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type JobBase struct {
|
||||||
|
ID int64
|
||||||
|
AgentID string
|
||||||
|
}
|
||||||
|
type JobForInsert struct {
|
||||||
|
Command []string
|
||||||
|
Stdin *string
|
||||||
|
}
|
||||||
|
type JobForUpdate struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Status int32
|
||||||
|
}
|
||||||
|
type Job struct {
|
||||||
|
JobBase
|
||||||
|
JobForInsert
|
||||||
|
Stdout *string
|
||||||
|
Stderr *string
|
||||||
|
Status *int32
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobRepository struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobRepository(db *sql.DB) *JobRepository {
|
||||||
|
return &JobRepository{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) Init(ctx context.Context) error {
|
||||||
|
_, err := r.DB.ExecContext(ctx, storage.CreateJobsTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) InitJob(
|
||||||
|
ctx context.Context,
|
||||||
|
agentID string,
|
||||||
|
job models.JobForInsert,
|
||||||
|
) (int64, error) {
|
||||||
|
commandJSON, err := json.Marshal(job.Command)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("marshal command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdinVal *string
|
||||||
|
if job.Stdin != nil {
|
||||||
|
stdinVal = job.Stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO jobs (agent_id, command, stdin, stdout, stderr, status) VALUES (?, ?, ?, '', '', 0)`,
|
||||||
|
agentID,
|
||||||
|
string(commandJSON),
|
||||||
|
stdinVal,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) UpdateJobInDB(
|
||||||
|
ctx context.Context,
|
||||||
|
jid int64,
|
||||||
|
msg models.JobForUpdate,
|
||||||
|
) (models.Job, error) {
|
||||||
|
result, err := r.DB.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE jobs SET stdout = ?, stderr = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
msg.Stdout,
|
||||||
|
msg.Stderr,
|
||||||
|
msg.Status,
|
||||||
|
jid,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return models.Job{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return models.Job{}, err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return models.Job{}, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetJobByID(ctx, jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job, error) {
|
||||||
|
var job models.Job
|
||||||
|
var commandJSON string
|
||||||
|
var stdinVal *string
|
||||||
|
|
||||||
|
err := r.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT id, agent_id, command, stdin, stdout, stderr, status FROM jobs WHERE id = ?`,
|
||||||
|
jid,
|
||||||
|
).Scan(&job.ID, &job.AgentID, &commandJSON, &stdinVal, &job.Stdout, &job.Stderr, &job.Status)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return models.Job{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return models.Job{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(commandJSON), &job.Command); err != nil {
|
||||||
|
return models.Job{}, fmt.Errorf("unmarshal command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Stdin = stdinVal
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobMetrics struct {
|
||||||
|
Total int
|
||||||
|
Success int
|
||||||
|
Failed int
|
||||||
|
Pending int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobMetrics returns job success metrics for jobs updated since the given time.
|
||||||
|
// If agentID is non-empty, results are filtered to that agent only.
|
||||||
|
func (r *JobRepository) GetJobMetrics(ctx context.Context, since time.Time, agentID string) (JobMetrics, error) {
|
||||||
|
var m JobMetrics
|
||||||
|
query := `SELECT
|
||||||
|
COUNT(*),
|
||||||
|
SUM(CASE WHEN status = 0 AND (stdout != '' OR stderr != '') THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN status != 0 THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN status = 0 AND stdout = '' AND stderr = '' THEN 1 ELSE 0 END)
|
||||||
|
FROM jobs WHERE updated_at >= ?`
|
||||||
|
args := []any{since}
|
||||||
|
if agentID != "" {
|
||||||
|
query += " AND agent_id = ?"
|
||||||
|
args = append(args, agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.DB.QueryRowContext(ctx, query, args...).Scan(&m.Total, &m.Success, &m.Failed, &m.Pending)
|
||||||
|
if err != nil {
|
||||||
|
return JobMetrics{}, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -2,44 +2,85 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LogRepository struct {
|
type LogRepository struct {
|
||||||
Conn driver.Conn
|
mu sync.RWMutex
|
||||||
|
DB *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLogRepository(conn driver.Conn) *LogRepository {
|
func NewLogRepository() *LogRepository {
|
||||||
return &LogRepository{Conn: conn}
|
return &LogRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) SetDB(db *sql.DB) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.DB = db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) IsConnected() bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.DB != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) getDB() *sql.DB {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) Init(ctx context.Context) error {
|
func (r *LogRepository) Init(ctx context.Context) error {
|
||||||
return r.Conn.Exec(ctx, storage.CreateLogsTable)
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, storage.CreateLogsTable)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) Insert(ctx context.Context, log storage.LogEntry) error {
|
func (r *LogRepository) Insert(ctx context.Context, log storage.LogEntry) error {
|
||||||
return r.Conn.Exec(ctx, `
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO logs (timestamp, level, service, agent, message)
|
INSERT INTO logs (timestamp, level, service, agent, message)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
`, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
`, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry) error {
|
func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry) error {
|
||||||
batch, err := r.Conn.PrepareBatch(ctx, "INSERT INTO logs (timestamp, level, service, agent, message)")
|
db := r.getDB()
|
||||||
if err != nil {
|
if db == nil {
|
||||||
return err
|
return nil
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, log := range logs {
|
// Build multi-row INSERT statement
|
||||||
if err := batch.Append(log.Timestamp, log.Level, log.Service, log.Agent, log.Message); err != nil {
|
query := "INSERT INTO logs (timestamp, level, service, agent, message) VALUES "
|
||||||
return err
|
args := make([]interface{}, 0, len(logs)*5)
|
||||||
|
for i, log := range logs {
|
||||||
|
if i > 0 {
|
||||||
|
query += ", "
|
||||||
}
|
}
|
||||||
|
query += fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)",
|
||||||
|
i*5+1, i*5+2, i*5+3, i*5+4, i*5+5)
|
||||||
|
args = append(args, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return batch.Send()
|
_, err := db.ExecContext(ctx, query, args...)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogFilter struct {
|
type LogFilter struct {
|
||||||
@@ -53,6 +94,11 @@ type LogFilter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage.LogEntry, error) {
|
func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage.LogEntry, error) {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []storage.LogEntry{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
query := "SELECT timestamp, level, service, agent, message FROM logs WHERE 1=1"
|
query := "SELECT timestamp, level, service, agent, message FROM logs WHERE 1=1"
|
||||||
args := make([]interface{}, 0)
|
args := make([]interface{}, 0)
|
||||||
argIdx := 1
|
argIdx := 1
|
||||||
@@ -102,16 +148,22 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage
|
|||||||
args = append(args, filter.Offset)
|
args = append(args, filter.Offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.Conn.Query(ctx, query, args...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var logs []storage.LogEntry
|
logs := make([]storage.LogEntry, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var log storage.LogEntry
|
var log storage.LogEntry
|
||||||
if err := rows.Scan(&log.Timestamp, &log.Level, &log.Service, &log.Agent, &log.Message); err != nil {
|
if err := rows.Scan(
|
||||||
|
&log.Timestamp,
|
||||||
|
&log.Level,
|
||||||
|
&log.Service,
|
||||||
|
&log.Agent,
|
||||||
|
&log.Message,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logs = append(logs, log)
|
logs = append(logs, log)
|
||||||
@@ -121,13 +173,17 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) GetDistinctServices(ctx context.Context) ([]string, error) {
|
func (r *LogRepository) GetDistinctServices(ctx context.Context) ([]string, error) {
|
||||||
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT service FROM logs ORDER BY service")
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT DISTINCT service FROM logs ORDER BY service")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var services []string
|
services := make([]string, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var service string
|
var service string
|
||||||
if err := rows.Scan(&service); err != nil {
|
if err := rows.Scan(&service); err != nil {
|
||||||
@@ -140,13 +196,17 @@ func (r *LogRepository) GetDistinctServices(ctx context.Context) ([]string, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) GetDistinctAgents(ctx context.Context) ([]string, error) {
|
func (r *LogRepository) GetDistinctAgents(ctx context.Context) ([]string, error) {
|
||||||
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT agent FROM logs ORDER BY agent")
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT DISTINCT agent FROM logs ORDER BY agent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var agents []string
|
agents := make([]string, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var agent string
|
var agent string
|
||||||
if err := rows.Scan(&agent); err != nil {
|
if err := rows.Scan(&agent); err != nil {
|
||||||
@@ -159,13 +219,17 @@ func (r *LogRepository) GetDistinctAgents(ctx context.Context) ([]string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *LogRepository) GetDistinctLevels(ctx context.Context) ([]string, error) {
|
func (r *LogRepository) GetDistinctLevels(ctx context.Context) ([]string, error) {
|
||||||
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT level FROM logs ORDER BY level")
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT DISTINCT level FROM logs ORDER BY level")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var levels []string
|
levels := make([]string, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var level string
|
var level string
|
||||||
if err := rows.Scan(&level); err != nil {
|
if err := rows.Scan(&level); err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Tokens struct {
|
|||||||
PermissionView bool `json:"permission_view"`
|
PermissionView bool `json:"permission_view"`
|
||||||
PermissionManage bool `json:"permission_manage_agent"`
|
PermissionManage bool `json:"permission_manage_agent"`
|
||||||
PermissionAdmin bool `json:"permission_admin"`
|
PermissionAdmin bool `json:"permission_admin"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenCreate is the request body for creating a new user.
|
// TokenCreate is the request body for creating a new user.
|
||||||
@@ -21,6 +22,39 @@ type TokenCreate struct {
|
|||||||
PermissionView bool `json:"permission_view"`
|
PermissionView bool `json:"permission_view"`
|
||||||
PermissionManage bool `json:"permission_manage_agent"`
|
PermissionManage bool `json:"permission_manage_agent"`
|
||||||
PermissionAdmin bool `json:"permission_admin"`
|
PermissionAdmin bool `json:"permission_admin"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRegister is the request body for public user registration (all permissions false).
|
||||||
|
type UserRegister struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
LastName string `json:"last_name" binding:"required"`
|
||||||
|
Login string `json:"login" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUpdate is the request body for updating an existing user.
|
||||||
|
type TokenUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUpdatePermissions is the request body for updating user permissions.
|
||||||
|
type TokenUpdatePermissions struct {
|
||||||
|
PermissionView *bool `json:"permission_view"`
|
||||||
|
PermissionManage *bool `json:"permission_manage_agent"`
|
||||||
|
PermissionAdmin *bool `json:"permission_admin"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPasswordReset is the request body for resetting a user's password.
|
||||||
|
type TokenPasswordReset struct {
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchActionRequest is the request body for batch activate/deactivate users.
|
||||||
|
type BatchActionRequest struct {
|
||||||
|
Logins []string `json:"logins" binding:"required,min=1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRequest is the request body for login.
|
// LoginRequest is the request body for login.
|
||||||
@@ -38,4 +72,114 @@ type LoginResponse struct {
|
|||||||
PermissionView bool `json:"permission_view"`
|
PermissionView bool `json:"permission_view"`
|
||||||
PermissionManage bool `json:"permission_manage_agent"`
|
PermissionManage bool `json:"permission_manage_agent"`
|
||||||
PermissionAdmin bool `json:"permission_admin"`
|
PermissionAdmin bool `json:"permission_admin"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationToken represents a one-time agent registration token.
|
||||||
|
type RegistrationToken struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Used bool `json:"used"`
|
||||||
|
CreatedAt *string `json:"created_at"`
|
||||||
|
UsedAt *string `json:"used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationRequest is the request body for creating a registration token.
|
||||||
|
type RegistrationRequest struct {
|
||||||
|
Label string `json:"label" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationResponse is returned when an agent registers.
|
||||||
|
type RegistrationResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
ClientCert string `json:"client_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployType represents the type of agent deployment
|
||||||
|
// @Description Type of deployment: docker or binary
|
||||||
|
type DeployType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeployTypeDocker DeployType = "docker"
|
||||||
|
DeployTypeBinary DeployType = "binary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMethod represents the SSH authentication method
|
||||||
|
// @Description SSH authentication method: key or password
|
||||||
|
type AuthMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthMethodKey AuthMethod = "key"
|
||||||
|
AuthMethodPassword AuthMethod = "password"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentDeployConfig represents the configuration for deploying an agent to a server
|
||||||
|
// @Description Configuration for deploying HellreigN agent to a single server
|
||||||
|
type AgentDeployConfig struct {
|
||||||
|
User string `json:"user" binding:"required" example:"admin" description:"SSH username"`
|
||||||
|
IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"`
|
||||||
|
Port int `json:"port" example:"22" description:"SSH port (default: 22)"`
|
||||||
|
AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"`
|
||||||
|
SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"`
|
||||||
|
Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"`
|
||||||
|
DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"`
|
||||||
|
AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployAgentsRequest represents the request body for deploying agents to multiple servers
|
||||||
|
// @Description Request to deploy HellreigN agents to multiple servers
|
||||||
|
type DeployAgentsRequest struct {
|
||||||
|
Servers []AgentDeployConfig `json:"servers" binding:"required,min=1,dive" description:"List of server configurations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResponse represents the response after deploying agents
|
||||||
|
// @Description Response containing deployment results and registration tokens
|
||||||
|
type DeployResponse struct {
|
||||||
|
Message string `json:"message" example:"Deployment completed"`
|
||||||
|
Results []DeployResult `json:"results" description:"Deployment results for each server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResult represents the result of deploying to a single server
|
||||||
|
// @Description Result of deploying to a single server
|
||||||
|
type DeployResult struct {
|
||||||
|
IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"`
|
||||||
|
AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"`
|
||||||
|
Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"`
|
||||||
|
Success bool `json:"success" example:"true" description:"Whether deployment succeeded"`
|
||||||
|
Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script represents a stored script with path and interpreter binding.
|
||||||
|
type Script struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
InterpreterID int64 `json:"interpreter_id"`
|
||||||
|
CreatedAt *string `json:"created_at"`
|
||||||
|
UpdatedAt *string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptCreate is the request body for creating a script.
|
||||||
|
type ScriptCreate struct {
|
||||||
|
Path string `json:"path" binding:"required"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
InterpreterID int64 `json:"interpreter_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptUpdate is the request body for updating a script.
|
||||||
|
type ScriptUpdate struct {
|
||||||
|
Path *string `json:"path"`
|
||||||
|
Content *string `json:"content"`
|
||||||
|
InterpreterID *int64 `json:"interpreter_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptTreeNode represents a node in the script directory tree.
|
||||||
|
type ScriptTreeNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"` // "folder" or "file"
|
||||||
|
Children []ScriptTreeNode `json:"children,omitempty"`
|
||||||
|
ID *int64 `json:"id,omitempty"`
|
||||||
|
Content *string `json:"content,omitempty"`
|
||||||
|
InterpreterID *int64 `json:"interpreter_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
@@ -21,14 +23,21 @@ func New(db *sql.DB) *Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ErrNotFound = errors.New("not found")
|
var ErrNotFound = errors.New("not found")
|
||||||
|
var ErrAccountInactive = errors.New("account is not activated")
|
||||||
|
|
||||||
// Init creates the tokens table if it does not exist.
|
// Init creates the tokens table if it does not exist.
|
||||||
func (r *Repository) Init() error {
|
func (r *Repository) Init() error {
|
||||||
_, err := r.DB.Exec(storage.CreateSqlite)
|
_, err := r.DB.Exec(storage.CreateSqlite)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Migration: add is_active column if it doesn't exist (SQLite ignores errors for duplicate column)
|
||||||
|
_, _ = r.DB.Exec(storage.AddIsActiveColumn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateToken inserts a new user record with hashed password and generated token.
|
// CreateToken inserts a new user record with hashed password and generated token.
|
||||||
|
// New users are created with is_active=false by default.
|
||||||
func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
|
func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
|
||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(tc.Password), bcrypt.DefaultCost)
|
hashed, err := bcrypt.GenerateFromPassword([]byte(tc.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,10 +50,17 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := r.DB.Exec(
|
result, err := r.DB.Exec(
|
||||||
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin)
|
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
tc.Name, tc.LastName, tc.Login, string(hashed), token,
|
tc.Name,
|
||||||
tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin,
|
tc.LastName,
|
||||||
|
tc.Login,
|
||||||
|
string(hashed),
|
||||||
|
token,
|
||||||
|
tc.PermissionView,
|
||||||
|
tc.PermissionManage,
|
||||||
|
tc.PermissionAdmin,
|
||||||
|
tc.IsActive,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -57,17 +73,50 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
|
|||||||
return strconv.FormatInt(id, 10), nil
|
return strconv.FormatInt(id, 10), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterUser inserts a new user with all permissions set to false and is_active=false.
|
||||||
|
func (r *Repository) RegisterUser(ur UserRegister) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(ur.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := utils.RandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0)`,
|
||||||
|
ur.Name,
|
||||||
|
ur.LastName,
|
||||||
|
ur.Login,
|
||||||
|
string(hashed),
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("insert user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[register] user created: id=%s login=%s", strconv.FormatInt(id, 10), ur.Login)
|
||||||
|
return strconv.FormatInt(id, 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Login authenticates by login/password, generates a new token, and returns LoginResponse.
|
// Login authenticates by login/password, generates a new token, and returns LoginResponse.
|
||||||
func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
||||||
var t Tokens
|
var t Tokens
|
||||||
var hashedPassword string
|
var hashedPassword string
|
||||||
|
|
||||||
err := r.DB.QueryRow(
|
err := r.DB.QueryRow(
|
||||||
`SELECT id, name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin
|
`SELECT id, name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
FROM tokens WHERE login = ?`,
|
FROM tokens WHERE login = ?`,
|
||||||
login,
|
login,
|
||||||
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &hashedPassword, &t.Token,
|
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &hashedPassword, &t.Token,
|
||||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin)
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -80,6 +129,10 @@ func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !t.IsActive {
|
||||||
|
return nil, ErrAccountInactive
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new token on each login
|
// Generate new token on each login
|
||||||
newToken, err := utils.RandomToken()
|
newToken, err := utils.RandomToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,6 +152,7 @@ func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
|||||||
PermissionView: t.PermissionView,
|
PermissionView: t.PermissionView,
|
||||||
PermissionManage: t.PermissionManage,
|
PermissionManage: t.PermissionManage,
|
||||||
PermissionAdmin: t.PermissionAdmin,
|
PermissionAdmin: t.PermissionAdmin,
|
||||||
|
IsActive: t.IsActive,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +160,11 @@ func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
|||||||
func (r *Repository) GetToken(token string) (*Tokens, error) {
|
func (r *Repository) GetToken(token string) (*Tokens, error) {
|
||||||
var t Tokens
|
var t Tokens
|
||||||
err := r.DB.QueryRow(
|
err := r.DB.QueryRow(
|
||||||
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin
|
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
FROM tokens WHERE token = ?`,
|
FROM tokens WHERE token = ?`,
|
||||||
token,
|
token,
|
||||||
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin)
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -124,7 +178,7 @@ func (r *Repository) GetToken(token string) (*Tokens, error) {
|
|||||||
// ListTokens returns all users without password and token.
|
// ListTokens returns all users without password and token.
|
||||||
func (r *Repository) ListTokens() ([]Tokens, error) {
|
func (r *Repository) ListTokens() ([]Tokens, error) {
|
||||||
rows, err := r.DB.Query(
|
rows, err := r.DB.Query(
|
||||||
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin
|
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
FROM tokens`,
|
FROM tokens`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,7 +190,7 @@ func (r *Repository) ListTokens() ([]Tokens, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Tokens
|
var t Tokens
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login,
|
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login,
|
||||||
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin); err != nil {
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tokens = append(tokens, t)
|
tokens = append(tokens, t)
|
||||||
@@ -185,3 +239,408 @@ func (r *Repository) ExistsByLogin(login string) bool {
|
|||||||
}
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitRegistrationTokens creates the registration_tokens table if it does not exist.
|
||||||
|
func (r *Repository) InitRegistrationTokens() error {
|
||||||
|
_, err := r.DB.Exec(storage.CreateRegistrationTokensTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistrationToken inserts a new one-time registration token.
|
||||||
|
func (r *Repository) CreateRegistrationToken(label string) (string, error) {
|
||||||
|
token, err := utils.RandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.DB.Exec(
|
||||||
|
`INSERT INTO registration_tokens (token, label, used) VALUES (?, ?, 0)`,
|
||||||
|
token, label,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistrationToken retrieves a registration token if it exists and is not used.
|
||||||
|
func (r *Repository) GetRegistrationToken(token string) (*RegistrationToken, error) {
|
||||||
|
var rt RegistrationToken
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, token, label, used, created_at, used_at FROM registration_tokens WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
).Scan(&rt.ID, &rt.Token, &rt.Label, &rt.Used, &rt.CreatedAt, &rt.UsedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRegistrationTokenUsed marks a registration token as used.
|
||||||
|
func (r *Repository) MarkRegistrationTokenUsed(token string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE registration_tokens SET used = 1, used_at = CURRENT_TIMESTAMP WHERE token = ? AND used = 0`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRegistrationToken deletes a registration token (used for rollback on deployment failure).
|
||||||
|
func (r *Repository) DeleteRegistrationToken(token string) error {
|
||||||
|
_, err := r.DB.Exec(`DELETE FROM registration_tokens WHERE token = ?`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateToken activates a user by token value.
|
||||||
|
func (r *Repository) ActivateToken(token string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 1 WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateToken deactivates a user by token value.
|
||||||
|
func (r *Repository) DeactivateToken(token string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 0 WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateUserByLogin activates a user by login.
|
||||||
|
func (r *Repository) ActivateUserByLogin(login string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 1 WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("activate exec: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rows affected: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[activate] login=%s affected=%d", login, affected)
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateUserByLogin deactivates a user by login.
|
||||||
|
func (r *Repository) DeactivateUserByLogin(login string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 0 WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInactiveTokens returns all users that are not activated.
|
||||||
|
func (r *Repository) ListInactiveTokens() ([]Tokens, error) {
|
||||||
|
rows, err := r.DB.Query(
|
||||||
|
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
|
FROM tokens WHERE is_active = 0`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tokens []Tokens
|
||||||
|
for rows.Next() {
|
||||||
|
var t Tokens
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, t)
|
||||||
|
}
|
||||||
|
return tokens, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenByLogin retrieves a user by login.
|
||||||
|
func (r *Repository) GetTokenByLogin(login string) (*Tokens, error) {
|
||||||
|
var t Tokens
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
|
FROM tokens WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateToken updates name and last_name for a user by login.
|
||||||
|
func (r *Repository) UpdateToken(login string, update TokenUpdate) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET name = ?, last_name = ? WHERE login = ?`,
|
||||||
|
update.Name, update.LastName, login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePermissions updates permissions and is_active for a user by login.
|
||||||
|
func (r *Repository) UpdatePermissions(login string, update TokenUpdatePermissions) error {
|
||||||
|
user, err := r.GetTokenByLogin(login)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing values if not provided
|
||||||
|
newView := user.PermissionView
|
||||||
|
newManage := user.PermissionManage
|
||||||
|
newAdmin := user.PermissionAdmin
|
||||||
|
newActive := user.IsActive
|
||||||
|
|
||||||
|
if update.PermissionView != nil {
|
||||||
|
newView = *update.PermissionView
|
||||||
|
}
|
||||||
|
if update.PermissionManage != nil {
|
||||||
|
newManage = *update.PermissionManage
|
||||||
|
}
|
||||||
|
if update.PermissionAdmin != nil {
|
||||||
|
newAdmin = *update.PermissionAdmin
|
||||||
|
}
|
||||||
|
if update.IsActive != nil {
|
||||||
|
newActive = *update.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET permission_view = ?, permission_manage_agent = ?, permission_admin = ?, is_active = ? WHERE login = ?`,
|
||||||
|
newView,
|
||||||
|
newManage,
|
||||||
|
newAdmin,
|
||||||
|
newActive,
|
||||||
|
login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword updates the password for a user by login.
|
||||||
|
func (r *Repository) UpdatePassword(login string, newPassword string) error {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET password = ? WHERE login = ?`,
|
||||||
|
string(hashed), login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateScript inserts a new script into the database.
|
||||||
|
func (r *Repository) CreateScript(sc ScriptCreate) (*Script, error) {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`INSERT INTO scripts (path, content, interpreter_id) VALUES (?, ?, ?)`,
|
||||||
|
sc.Path, sc.Content, sc.InterpreterID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get last insert id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Script{
|
||||||
|
ID: id,
|
||||||
|
Path: sc.Path,
|
||||||
|
Content: sc.Content,
|
||||||
|
InterpreterID: sc.InterpreterID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScript retrieves a script by ID.
|
||||||
|
func (r *Repository) GetScript(id int64) (*Script, error) {
|
||||||
|
var s Script
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts WHERE id = ?`,
|
||||||
|
id,
|
||||||
|
).Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScriptByPath retrieves a script by its path.
|
||||||
|
func (r *Repository) GetScriptByPath(path string) (*Script, error) {
|
||||||
|
var s Script
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts WHERE path = ?`,
|
||||||
|
path,
|
||||||
|
).Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScripts returns all scripts.
|
||||||
|
func (r *Repository) ListScripts() ([]Script, error) {
|
||||||
|
rows, err := r.DB.Query(
|
||||||
|
`SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var scripts []Script
|
||||||
|
for rows.Next() {
|
||||||
|
var s Script
|
||||||
|
if err := rows.Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scripts = append(scripts, s)
|
||||||
|
}
|
||||||
|
return scripts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateScript updates a script by ID.
|
||||||
|
func (r *Repository) UpdateScript(id int64, update ScriptUpdate) (*Script, error) {
|
||||||
|
existing, err := r.GetScript(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := existing.Path
|
||||||
|
newContent := existing.Content
|
||||||
|
newInterpreterID := existing.InterpreterID
|
||||||
|
|
||||||
|
if update.Path != nil {
|
||||||
|
newPath = *update.Path
|
||||||
|
}
|
||||||
|
if update.Content != nil {
|
||||||
|
newContent = *update.Content
|
||||||
|
}
|
||||||
|
if update.InterpreterID != nil {
|
||||||
|
newInterpreterID = *update.InterpreterID
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.DB.Exec(
|
||||||
|
`UPDATE scripts SET path = ?, content = ?, interpreter_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
newPath, newContent, newInterpreterID, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Script{
|
||||||
|
ID: id,
|
||||||
|
Path: newPath,
|
||||||
|
Content: newContent,
|
||||||
|
InterpreterID: newInterpreterID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteScript deletes a script by ID.
|
||||||
|
func (r *Repository) DeleteScript(id int64) error {
|
||||||
|
result, err := r.DB.Exec(`DELETE FROM scripts WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptInterpreter struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptInterpreterCreate struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Label string `json:"label" binding:"required"`
|
||||||
|
Argv []string `json:"argv" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptInterpreterUpdate struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Label *string `json:"label"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptInterpreterRepo struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptInterpreterRepo(db *sql.DB) *ScriptInterpreterRepo {
|
||||||
|
return &ScriptInterpreterRepo{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Init(ctx context.Context) error {
|
||||||
|
_, err := r.DB.ExecContext(ctx, storage.CreateScriptInterpretersTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
in ScriptInterpreterCreate,
|
||||||
|
) (*ScriptInterpreter, error) {
|
||||||
|
argvJSON, err := json.Marshal(in.Argv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.ExecContext(ctx,
|
||||||
|
`INSERT INTO script_interpreters (name, label, argv) VALUES (?, ?, ?)`,
|
||||||
|
in.Name, in.Label, string(argvJSON),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptInterpreter, error) {
|
||||||
|
var si ScriptInterpreter
|
||||||
|
var argvJSON string
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
|
||||||
|
err := r.DB.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters WHERE id = ?`,
|
||||||
|
id,
|
||||||
|
).Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
return &si, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]ScriptInterpreter, error) {
|
||||||
|
rows, err := r.DB.QueryContext(ctx,
|
||||||
|
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var interpreters []ScriptInterpreter
|
||||||
|
for rows.Next() {
|
||||||
|
var si ScriptInterpreter
|
||||||
|
var argvJSON, createdAt, updatedAt string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&si.ID,
|
||||||
|
&si.Name,
|
||||||
|
&si.Label,
|
||||||
|
&argvJSON,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
interpreters = append(interpreters, si)
|
||||||
|
}
|
||||||
|
return interpreters, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Update(
|
||||||
|
ctx context.Context,
|
||||||
|
id int64,
|
||||||
|
in ScriptInterpreterUpdate,
|
||||||
|
) (*ScriptInterpreter, error) {
|
||||||
|
si, err := r.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
set := ""
|
||||||
|
args := make([]interface{}, 0)
|
||||||
|
idx := 1
|
||||||
|
|
||||||
|
if in.Name != nil {
|
||||||
|
set += "name = ?"
|
||||||
|
args = append(args, *in.Name)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if in.Label != nil {
|
||||||
|
if idx > 1 {
|
||||||
|
set += ", "
|
||||||
|
}
|
||||||
|
set += "label = ?"
|
||||||
|
args = append(args, *in.Label)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if in.Argv != nil {
|
||||||
|
if idx > 1 {
|
||||||
|
set += ", "
|
||||||
|
}
|
||||||
|
argvJSON, err := json.Marshal(in.Argv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set += "argv = ?"
|
||||||
|
args = append(args, string(argvJSON))
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx == 1 {
|
||||||
|
return si, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
set += ", updated_at = CURRENT_TIMESTAMP"
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
_, err = r.DB.ExecContext(ctx,
|
||||||
|
`UPDATE script_interpreters SET `+set+` WHERE id = ?`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
result, err := r.DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM script_interpreters WHERE id = ?`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScriptService handles script CRUD, tree building, and interpreter resolution.
|
||||||
|
type ScriptService struct {
|
||||||
|
Repo *repository.Repository
|
||||||
|
InterpreterRepo *repository.ScriptInterpreterRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScriptService creates a new ScriptService with both script and interpreter repos.
|
||||||
|
func NewScriptService(repo *repository.Repository) *ScriptService {
|
||||||
|
return &ScriptService{Repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScriptServiceWithInterpreters creates a ScriptService with interpreter support.
|
||||||
|
func NewScriptServiceWithInterpreters(repo *repository.Repository, interpRepo *repository.ScriptInterpreterRepo) *ScriptService {
|
||||||
|
return &ScriptService{Repo: repo, InterpreterRepo: interpRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeNode is an internal representation for building the tree.
|
||||||
|
type treeNode struct {
|
||||||
|
name string
|
||||||
|
typ string // "folder" or "file"
|
||||||
|
children map[string]*treeNode
|
||||||
|
// File-specific fields
|
||||||
|
id *int64
|
||||||
|
content *string
|
||||||
|
interpreterID *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTree builds a directory tree from all scripts in the database.
|
||||||
|
// Each script path is treated as a file path (e.g. "deploy/nginx/restart.sh").
|
||||||
|
// Scripts with empty content and interpreter_id=0 are treated as folder placeholders.
|
||||||
|
func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) {
|
||||||
|
scripts, err := s.Repo.ListScripts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
root := make(map[string]*treeNode)
|
||||||
|
|
||||||
|
for _, sc := range scripts {
|
||||||
|
parts := strings.Split(sc.Path, "/")
|
||||||
|
|
||||||
|
// A script with empty content and interpreter_id=0 is a folder placeholder
|
||||||
|
isPlaceholder := sc.Content == "" && sc.InterpreterID == 0
|
||||||
|
|
||||||
|
// Walk through path parts, creating folders as needed
|
||||||
|
currentMap := root
|
||||||
|
for i, part := range parts {
|
||||||
|
isLastPart := i == len(parts)-1
|
||||||
|
isFile := isLastPart && !isPlaceholder
|
||||||
|
|
||||||
|
if _, exists := currentMap[part]; !exists {
|
||||||
|
node := &treeNode{
|
||||||
|
name: part,
|
||||||
|
children: make(map[string]*treeNode),
|
||||||
|
}
|
||||||
|
if isFile {
|
||||||
|
node.typ = "file"
|
||||||
|
id := sc.ID
|
||||||
|
content := sc.Content
|
||||||
|
interpreterID := sc.InterpreterID
|
||||||
|
node.id = &id
|
||||||
|
node.content = &content
|
||||||
|
node.interpreterID = &interpreterID
|
||||||
|
} else {
|
||||||
|
node.typ = "folder"
|
||||||
|
}
|
||||||
|
currentMap[part] = node
|
||||||
|
} else if isFile {
|
||||||
|
// Node already exists but was created as a folder (e.g. by another script's path).
|
||||||
|
// Convert it to a file if it was a folder placeholder.
|
||||||
|
existing := currentMap[part]
|
||||||
|
if existing.typ == "folder" {
|
||||||
|
id := sc.ID
|
||||||
|
content := sc.Content
|
||||||
|
interpreterID := sc.InterpreterID
|
||||||
|
existing.typ = "file"
|
||||||
|
existing.id = &id
|
||||||
|
existing.content = &content
|
||||||
|
existing.interpreterID = &interpreterID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentMap = currentMap[part].children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTreeSlice(root), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTreeSlice converts a map of treeNodes to a sorted slice of ScriptTreeNode.
|
||||||
|
func buildTreeSlice(m map[string]*treeNode) []repository.ScriptTreeNode {
|
||||||
|
result := make([]repository.ScriptTreeNode, 0, len(m))
|
||||||
|
for _, node := range m {
|
||||||
|
result = append(result, toScriptTreeNode(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: folders first, then files, alphabetically within each group
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
if result[i].Type != result[j].Type {
|
||||||
|
return result[i].Type == "folder"
|
||||||
|
}
|
||||||
|
return result[i].Name < result[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// toScriptTreeNode converts a treeNode to a ScriptTreeNode with recursively converted children.
|
||||||
|
func toScriptTreeNode(node *treeNode) repository.ScriptTreeNode {
|
||||||
|
result := repository.ScriptTreeNode{
|
||||||
|
Name: node.name,
|
||||||
|
Type: node.typ,
|
||||||
|
Children: []repository.ScriptTreeNode{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.typ == "file" {
|
||||||
|
result.ID = node.id
|
||||||
|
result.Content = node.content
|
||||||
|
result.InterpreterID = node.interpreterID
|
||||||
|
} else {
|
||||||
|
result.Children = buildTreeSlice(node.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCommand resolves the full command for a script using its interpreter.
|
||||||
|
func (s *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) {
|
||||||
|
if s.InterpreterRepo == nil {
|
||||||
|
return nil, fmt.Errorf("interpreter repo not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
interpreter, err := s.InterpreterRepo.GetByID(ctx, interpreterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get interpreter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build command: argv[0] argv[1] ... -c scriptText
|
||||||
|
cmd := append(interpreter.Argv, "-c", scriptText)
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all interpreters.
|
||||||
|
func (s *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) {
|
||||||
|
if s.InterpreterRepo == nil {
|
||||||
|
return nil, fmt.Errorf("interpreter repo not configured")
|
||||||
|
}
|
||||||
|
return s.InterpreterRepo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new interpreter.
|
||||||
|
func (s *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) {
|
||||||
|
if s.InterpreterRepo == nil {
|
||||||
|
return nil, fmt.Errorf("interpreter repo not configured")
|
||||||
|
}
|
||||||
|
return s.InterpreterRepo.Create(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns an interpreter by ID.
|
||||||
|
func (s *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) {
|
||||||
|
if s.InterpreterRepo == nil {
|
||||||
|
return nil, fmt.Errorf("interpreter repo not configured")
|
||||||
|
}
|
||||||
|
return s.InterpreterRepo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an interpreter.
|
||||||
|
func (s *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) {
|
||||||
|
if s.InterpreterRepo == nil {
|
||||||
|
return nil, fmt.Errorf("interpreter repo not configured")
|
||||||
|
}
|
||||||
|
return s.InterpreterRepo.Update(ctx, id, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes an interpreter.
|
||||||
|
func (s *ScriptService) Delete(ctx context.Context, id int64) error {
|
||||||
|
if s.InterpreterRepo == nil {
|
||||||
|
return fmt.Errorf("interpreter repo not configured")
|
||||||
|
}
|
||||||
|
return s.InterpreterRepo.Delete(ctx, id)
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ClickHouse/clickhouse-go/v2"
|
_ "github.com/ClickHouse/clickhouse-go/v2"
|
||||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ClickHouseConfig struct {
|
type ClickHouseConfig struct {
|
||||||
@@ -15,33 +17,60 @@ type ClickHouseConfig struct {
|
|||||||
Database string
|
Database string
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenClickHouse(cfg ClickHouseConfig) (driver.Conn, error) {
|
func OpenClickHouse(cfg ClickHouseConfig) (*sql.DB, error) {
|
||||||
conn, err := clickhouse.Open(&clickhouse.Options{
|
dsn := fmt.Sprintf("clickhouse://%s:%s@%s/%s",
|
||||||
Addr: []string{cfg.Host},
|
cfg.User, cfg.Password, cfg.Host, cfg.Database)
|
||||||
Auth: clickhouse.Auth{
|
|
||||||
Database: cfg.Database,
|
db, err := sql.Open("clickhouse", dsn)
|
||||||
Username: cfg.User,
|
|
||||||
Password: cfg.Password,
|
|
||||||
},
|
|
||||||
Settings: clickhouse.Settings{
|
|
||||||
"max_execution_time": 60,
|
|
||||||
},
|
|
||||||
Compression: &clickhouse.Compression{
|
|
||||||
Method: clickhouse.CompressionLZ4,
|
|
||||||
},
|
|
||||||
DialTimeout: 30,
|
|
||||||
MaxOpenConns: 10,
|
|
||||||
MaxIdleConns: 5,
|
|
||||||
ConnMaxLifetime: 3600,
|
|
||||||
ConnOpenStrategy: clickhouse.ConnOpenInOrder,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("clickhouse connect: %w", err)
|
return nil, fmt.Errorf("clickhouse open: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := conn.Ping(context.Background()); err != nil {
|
db.SetMaxOpenConns(5)
|
||||||
|
db.SetMaxIdleConns(2)
|
||||||
|
db.SetConnMaxLifetime(10 * time.Minute)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
return nil, fmt.Errorf("clickhouse ping: %w", err)
|
return nil, fmt.Errorf("clickhouse ping: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return conn, nil
|
log.Printf("ClickHouse connected via database/sql: %s", cfg.Host)
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenClickHouseWithRetry attempts to connect to ClickHouse with retries and backoff.
|
||||||
|
func OpenClickHouseWithRetry(
|
||||||
|
cfg ClickHouseConfig,
|
||||||
|
maxRetries int,
|
||||||
|
initialDelay time.Duration,
|
||||||
|
) (*sql.DB, error) {
|
||||||
|
var lastErr error
|
||||||
|
delay := initialDelay
|
||||||
|
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
db, err := OpenClickHouse(cfg)
|
||||||
|
if err == nil {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
log.Printf(
|
||||||
|
"ClickHouse connection attempt %d/%d failed: %v, retrying in %v...",
|
||||||
|
i+1,
|
||||||
|
maxRetries,
|
||||||
|
err,
|
||||||
|
delay,
|
||||||
|
)
|
||||||
|
time.Sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"clickhouse connection failed after %d attempts: %w",
|
||||||
|
maxRetries,
|
||||||
|
lastErr,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,62 @@ const CreateSqlite = `
|
|||||||
token TEXT NOT NULL UNIQUE,
|
token TEXT NOT NULL UNIQUE,
|
||||||
permission_view BOOL NOT NULL,
|
permission_view BOOL NOT NULL,
|
||||||
permission_manage_agent BOOL NOT NULL,
|
permission_manage_agent BOOL NOT NULL,
|
||||||
permission_admin BOOL NOT NULL
|
permission_admin BOOL NOT NULL,
|
||||||
|
is_active BOOL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
// AddIsActiveColumn adds is_active column to tokens table if it doesn't exist.
|
||||||
|
// This is a migration for existing databases that don't have this column.
|
||||||
|
const AddIsActiveColumn = `
|
||||||
|
ALTER TABLE tokens ADD COLUMN is_active BOOL NOT NULL DEFAULT 0
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateRegistrationTokensTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS registration_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
used BOOL NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
used_at DATETIME
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateJobsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
stdin TEXT,
|
||||||
|
stdout TEXT DEFAULT '',
|
||||||
|
stderr TEXT DEFAULT '',
|
||||||
|
status INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateScriptInterpretersTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS script_interpreters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
argv TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateScriptsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS scripts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
interpreter_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (interpreter_id) REFERENCES script_interpreters(id)
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -26,3 +81,297 @@ ORDER BY (timestamp, level, service, agent)
|
|||||||
TTL timestamp + INTERVAL 30 DAY
|
TTL timestamp + INTERVAL 30 DAY
|
||||||
SETTINGS index_granularity = 8192
|
SETTINGS index_granularity = 8192
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// SeedDefaultScripts inserts the bash interpreter and default diagnostic scripts.
|
||||||
|
// Uses INSERT OR IGNORE to avoid duplicates on subsequent runs.
|
||||||
|
const SeedDefaultScripts = `
|
||||||
|
-- Create bash interpreter with id=2
|
||||||
|
INSERT OR IGNORE INTO script_interpreters (id, name, label, argv) VALUES
|
||||||
|
(2, 'bash', 'Bash Shell', '["/bin/bash"]');
|
||||||
|
|
||||||
|
-- Insert default scripts bound to bash interpreter (id=2)
|
||||||
|
INSERT OR IGNORE INTO scripts (path, content, interpreter_id) VALUES
|
||||||
|
('default/system_info.sh', '#!/bin/bash
|
||||||
|
# Скрипт сбора базовой информации о системе: hostname, IP-адреса, сетевые интерфейсы, версия ОС
|
||||||
|
|
||||||
|
echo "=== SYSTEM INFORMATION ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Hostname
|
||||||
|
echo "--- Hostname ---"
|
||||||
|
hostname 2>/dev/null || echo "hostname command failed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# OS Version
|
||||||
|
echo "--- OS Version ---"
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
cat /etc/os-release
|
||||||
|
elif [ -f /etc/redhat-release ]; then
|
||||||
|
cat /etc/redhat-release
|
||||||
|
elif command -v uname >/dev/null 2>&1; then
|
||||||
|
uname -a
|
||||||
|
else
|
||||||
|
echo "Unable to determine OS version"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Network Interfaces
|
||||||
|
echo "--- Network Interfaces ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip addr show 2>/dev/null
|
||||||
|
elif command -v ifconfig >/dev/null 2>&1; then
|
||||||
|
ifconfig -a 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "Neither ip nor ifconfig available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# IP Addresses (summary)
|
||||||
|
echo "--- IP Addresses Summary ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip -brief addr show 2>/dev/null || ip addr show | grep "inet " | awk ''{print $2, $4}''
|
||||||
|
elif command -v ifconfig >/dev/null 2>&1; then
|
||||||
|
ifconfig | grep "inet " | awk ''{print $2}''
|
||||||
|
else
|
||||||
|
echo "Unable to retrieve IP addresses"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Default Gateway
|
||||||
|
echo "--- Default Gateway ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip route show default 2>/dev/null | head -5
|
||||||
|
elif command -v route >/dev/null 2>&1; then
|
||||||
|
route -n | grep "^0.0.0.0"
|
||||||
|
else
|
||||||
|
echo "Unable to determine default gateway"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# DNS Configuration
|
||||||
|
echo "--- DNS Configuration ---"
|
||||||
|
if [ -f /etc/resolv.conf ]; then
|
||||||
|
cat /etc/resolv.conf
|
||||||
|
else
|
||||||
|
echo "/etc/resolv.conf not found"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== END SYSTEM INFORMATION ==="', 2),
|
||||||
|
|
||||||
|
('default/services_scan.sh', '#!/bin/bash
|
||||||
|
# Скрипт сканирования доступных сервисов и портов на машине
|
||||||
|
|
||||||
|
echo "=== SERVICES AND PORTS SCAN ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Listening ports
|
||||||
|
echo "--- Listening Ports ---"
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
echo "Using ss:"
|
||||||
|
ss -tulnp 2>/dev/null
|
||||||
|
elif command -v netstat >/dev/null 2>&1; then
|
||||||
|
echo "Using netstat:"
|
||||||
|
netstat -tulnp 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "Neither ss nor netstat available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Common services check
|
||||||
|
echo "--- Common Services Check ---"
|
||||||
|
COMMON_PORTS="22 80 443 3306 5432 6379 8080 8443 27017 9200"
|
||||||
|
for port in $COMMON_PORTS; do
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
if ss -tuln | grep -q ":${port} "; then
|
||||||
|
echo "Port ${port}: LISTENING"
|
||||||
|
fi
|
||||||
|
elif command -v netstat >/dev/null 2>&1; then
|
||||||
|
if netstat -tuln | grep -q ":${port} "; then
|
||||||
|
echo "Port ${port}: LISTENING"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Running services
|
||||||
|
echo "--- Running Services (systemd) ---"
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -30
|
||||||
|
else
|
||||||
|
echo "systemctl not available"
|
||||||
|
echo "--- Running processes (top 20) ---"
|
||||||
|
ps aux --sort=-%mem 2>/dev/null | head -20 || ps aux | head -20
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Docker containers (if available)
|
||||||
|
echo "--- Docker Containers ---"
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo "Docker command failed"
|
||||||
|
else
|
||||||
|
echo "Docker not installed"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== END SERVICES AND PORTS SCAN ==="', 2),
|
||||||
|
|
||||||
|
('default/diagnostics.sh', '#!/bin/bash
|
||||||
|
# Скрипт выполнения базовых диагностических команд
|
||||||
|
|
||||||
|
echo "=== DIAGNOSTIC COMMANDS ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Uptime
|
||||||
|
echo "--- Uptime ---"
|
||||||
|
uptime 2>/dev/null || echo "uptime command failed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Load average
|
||||||
|
echo "--- Load Average ---"
|
||||||
|
cat /proc/loadavg 2>/dev/null || echo "/proc/loadavg not available"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Memory usage
|
||||||
|
echo "--- Memory Usage ---"
|
||||||
|
if command -v free >/dev/null 2>&1; then
|
||||||
|
free -h 2>/dev/null
|
||||||
|
elif [ -f /proc/meminfo ]; then
|
||||||
|
head -10 /proc/meminfo
|
||||||
|
else
|
||||||
|
echo "Unable to retrieve memory info"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Disk usage
|
||||||
|
echo "--- Disk Usage ---"
|
||||||
|
df -h 2>/dev/null || echo "df command failed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# CPU info
|
||||||
|
echo "--- CPU Info ---"
|
||||||
|
if [ -f /proc/cpuinfo ]; then
|
||||||
|
echo "CPU cores: $(grep -c ^processor /proc/cpuinfo 2>/dev/null || echo ''unknown'')"
|
||||||
|
grep "model name" /proc/cpuinfo 2>/dev/null | head -1 || echo "CPU model unknown"
|
||||||
|
else
|
||||||
|
echo "/proc/cpuinfo not available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Top processes by CPU
|
||||||
|
echo "--- Top 10 Processes by CPU ---"
|
||||||
|
ps aux --sort=-%cpu 2>/dev/null | head -11 || ps aux | head -11
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Network connectivity check
|
||||||
|
echo "--- Network Connectivity ---"
|
||||||
|
echo "Pinging 8.8.8.8..."
|
||||||
|
ping -c 2 -W 2 8.8.8.8 2>/dev/null || echo "Ping to 8.8.8.8 failed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Pinging 1.1.1.1..."
|
||||||
|
ping -c 2 -W 2 1.1.1.1 2>/dev/null || echo "Ping to 1.1.1.1 failed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Last reboots
|
||||||
|
echo "--- Last Reboots (last 5) ---"
|
||||||
|
last reboot 2>/dev/null | head -5 || echo "Unable to get reboot history"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Systemd failed services
|
||||||
|
echo "--- Failed Systemd Services ---"
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl list-units --state=failed --no-pager 2>/dev/null | head -10 || echo "No failed services or systemctl unavailable"
|
||||||
|
else
|
||||||
|
echo "systemctl not available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== END DIAGNOSTIC COMMANDS ==="', 2),
|
||||||
|
|
||||||
|
('default/network_info.sh', '#!/bin/bash
|
||||||
|
# Скрипт сбора базовой сетевой информации
|
||||||
|
|
||||||
|
echo "=== NETWORK INFORMATION ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Network interfaces with IPs
|
||||||
|
echo "--- Network Interfaces ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip addr show 2>/dev/null
|
||||||
|
elif command -v ifconfig >/dev/null 2>&1; then
|
||||||
|
ifconfig -a 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "Unable to retrieve network interface info"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Routing table
|
||||||
|
echo "--- Routing Table ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip route show 2>/dev/null
|
||||||
|
elif command -v route >/dev/null 2>&1; then
|
||||||
|
route -n 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "Unable to retrieve routing table"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ARP table
|
||||||
|
echo "--- ARP Table ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip neigh show 2>/dev/null
|
||||||
|
elif command -v arp >/dev/null 2>&1; then
|
||||||
|
arp -an 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "Unable to retrieve ARP table"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# DNS resolution test
|
||||||
|
echo "--- DNS Resolution Test ---"
|
||||||
|
echo "Resolving google.com..."
|
||||||
|
if command -v nslookup >/dev/null 2>&1; then
|
||||||
|
nslookup google.com 2>/dev/null | head -10
|
||||||
|
elif command -v dig >/dev/null 2>&1; then
|
||||||
|
dig google.com +short 2>/dev/null
|
||||||
|
elif command -v host >/dev/null 2>&1; then
|
||||||
|
host google.com 2>/dev/null | head -5
|
||||||
|
elif command -v getent >/dev/null 2>&1; then
|
||||||
|
getent hosts google.com 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "No DNS tools available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Active connections
|
||||||
|
echo "--- Active Connections (ESTABLISHED) ---"
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
ss -tnp state established 2>/dev/null | head -20
|
||||||
|
elif command -v netstat >/dev/null 2>&1; then
|
||||||
|
netstat -tnp 2>/dev/null | grep ESTABLISHED | head -20
|
||||||
|
else
|
||||||
|
echo "Unable to retrieve active connections"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Firewall rules (if accessible)
|
||||||
|
echo "--- Firewall Rules ---"
|
||||||
|
if command -v iptables >/dev/null 2>&1; then
|
||||||
|
iptables -L -n 2>/dev/null | head -30 || echo "iptables: permission denied or error"
|
||||||
|
else
|
||||||
|
echo "iptables not available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Network namespaces (if applicable)
|
||||||
|
echo "--- Network Namespaces ---"
|
||||||
|
if command -v ip >/dev/null 2>&1; then
|
||||||
|
ip netns list 2>/dev/null || echo "No network namespaces or permission denied"
|
||||||
|
else
|
||||||
|
echo "ip command not available"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== END NETWORK INFORMATION ==="', 2);
|
||||||
|
`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -36,5 +37,24 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
return nil, fmt.Errorf("migrate: %w", err)
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: add is_active column if it doesn't exist
|
||||||
|
if _, err := db.Exec(AddIsActiveColumn); err != nil {
|
||||||
|
log.Printf("[sqlite] WARNING: failed to add is_active column: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("[sqlite] is_active column migration applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create scripts table if not exists
|
||||||
|
if _, err := db.Exec(CreateScriptsTable); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate scripts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default diagnostic scripts
|
||||||
|
if _, err := db.Exec(SeedDefaultScripts); err != nil {
|
||||||
|
log.Printf("[sqlite] WARNING: failed to seed default scripts: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("[sqlite] default scripts seeded successfully")
|
||||||
|
}
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CertBundle holds CA and server certificates loaded from disk.
|
||||||
|
type CertBundle struct {
|
||||||
|
CACert *x509.Certificate
|
||||||
|
CAKey *rsa.PrivateKey
|
||||||
|
ServerCert *x509.Certificate
|
||||||
|
ServerKey *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCertBundle loads CA and server certificates from the given directory.
|
||||||
|
func LoadCertBundle(certDir string) (*CertBundle, error) {
|
||||||
|
caCertPEM, err := os.ReadFile(filepath.Join(certDir, "ca.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ca.crt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caKeyPEM, err := os.ReadFile(filepath.Join(certDir, "ca.key"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ca.key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCertPEM, err := os.ReadFile(filepath.Join(certDir, "server.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read server.crt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverKeyPEM, err := os.ReadFile(filepath.Join(certDir, "server.key"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read server.key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCert := decodeCert(caCertPEM)
|
||||||
|
caKey, err := decodeRSAPrivateKey(caKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ca.key: %w", err)
|
||||||
|
}
|
||||||
|
serverCert := decodeCert(serverCertPEM)
|
||||||
|
serverKey, err := decodeRSAPrivateKey(serverKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse server.key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CertBundle{
|
||||||
|
CACert: caCert,
|
||||||
|
CAKey: caKey,
|
||||||
|
ServerCert: serverCert,
|
||||||
|
ServerKey: serverKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignCSR signs a client CSR with the CA and returns the client certificate PEM.
|
||||||
|
func (b *CertBundle) SignCSR(csrPEM []byte, label string) ([]byte, error) {
|
||||||
|
csr := decodeCSR(csrPEM)
|
||||||
|
|
||||||
|
// Verify CSR signature
|
||||||
|
if err := csr.CheckSignature(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid CSR signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate serial: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: label,
|
||||||
|
Organization: csr.Subject.Organization,
|
||||||
|
},
|
||||||
|
NotBefore: now,
|
||||||
|
NotAfter: now.Add(365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &template, b.CACert, csr.PublicKey, b.CAKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: certDER,
|
||||||
|
})
|
||||||
|
|
||||||
|
return certPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM returns the CA certificate as PEM bytes.
|
||||||
|
func (b *CertBundle) GetCACertPEM() []byte {
|
||||||
|
return pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: b.CACert.Raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCert(pemData []byte) *x509.Certificate {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRSAPrivateKey(pemData []byte) (*rsa.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
// Try PKCS1 fallback
|
||||||
|
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse PKCS1: %w", err)
|
||||||
|
}
|
||||||
|
return key.(*rsa.PrivateKey), nil
|
||||||
|
}
|
||||||
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("key is not RSA, got %T", key)
|
||||||
|
}
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCSR(pemData []byte) *x509.CertificateRequest {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return csr
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TOOD: fuck
|
||||||
func RandomToken() (string, error) {
|
func RandomToken() (string, error) {
|
||||||
token := make([]byte, 32)
|
token := make([]byte, 32)
|
||||||
if _, err := rand.Read(token); err != nil {
|
if _, err := rand.Read(token); err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
CERT_DIR="${1:-/etc/mnemosyne/ssl}"
|
CERT_DIR="${1:-/etc/HellreigN/ssl}"
|
||||||
DAYS_VALID=365
|
DAYS_VALID=365
|
||||||
|
|
||||||
echo "Generating CA and server certificates in ${CERT_DIR}..."
|
echo "Generating CA and server certificates in ${CERT_DIR}..."
|
||||||
@@ -26,7 +26,7 @@ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "${CERT_DIR}/c
|
|||||||
openssl req -x509 -new -nodes -sha256 -days ${DAYS_VALID} \
|
openssl req -x509 -new -nodes -sha256 -days ${DAYS_VALID} \
|
||||||
-key "${CERT_DIR}/ca.key" \
|
-key "${CERT_DIR}/ca.key" \
|
||||||
-out "${CERT_DIR}/ca.crt" \
|
-out "${CERT_DIR}/ca.crt" \
|
||||||
-subj "/CN=Mnemosyne Root CA"
|
-subj "/CN=HellreigN Root CA"
|
||||||
|
|
||||||
# Генерация серверного сертификата
|
# Генерация серверного сертификата
|
||||||
echo "Generating server certificate..."
|
echo "Generating server certificate..."
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Backend API URL. По умолчанию "/api/v1" (через nginx proxy).
|
||||||
|
# Для локальной разработки: "http://localhost:8080/api/v1"
|
||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"Bash(type *)",
|
"Bash(type *)",
|
||||||
"Bash(dir)",
|
"Bash(dir)",
|
||||||
"Bash(move *)",
|
"Bash(move *)",
|
||||||
"Bash(findstr *)"
|
"Bash(findstr *)",
|
||||||
|
"Bash(del *)",
|
||||||
|
"Bash(mkdir *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ FROM node:25-alpine3.23 AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VITE_API_BASE_URL=/api/v1
|
||||||
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|||||||
+2
-4
@@ -9,15 +9,13 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_http_version 1.1;
|
proxy_pass http://backend:8080;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+7152
File diff suppressed because it is too large
Load Diff
@@ -11,19 +11,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@uiw/react-codemirror": "^4.25.8",
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"file-surf": "^1.0.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"monaco-languageclient": "^10.7.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.7",
|
"primereact": "^10.9.7",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vscode-ws-jsonrpc": "^3.5.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import "@/shared/styles/index.css";
|
import "@/shared/styles/index.css";
|
||||||
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
||||||
import "primereact/resources/primereact.min.css";
|
import "primereact/resources/primereact.min.css";
|
||||||
import "primeicons/primeicons.css";
|
import "primeicons/primeicons.css";
|
||||||
import { PrimeReactProvider } from "primereact/api";
|
import { PrimeReactProvider } from "primereact/api";
|
||||||
import { Routing } from "./providers/routing/routing";
|
import { Routing } from "./providers/routing/routing";
|
||||||
|
import { AppLoader } from "./components/AppLoader";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <AppLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimeReactProvider>
|
<PrimeReactProvider>
|
||||||
<Routing />
|
<Routing />
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FaMicrochip, FaCode, FaNetworkWired, FaAtom } from "react-icons/fa";
|
||||||
|
|
||||||
|
export const AppLoader = () => {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [phase, setPhase] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const phases = [
|
||||||
|
{ progress: 25, delay: 400 },
|
||||||
|
{ progress: 50, delay: 300 },
|
||||||
|
{ progress: 75, delay: 400 },
|
||||||
|
{ progress: 100, delay: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let timeouts: NodeJS.Timeout[] = [];
|
||||||
|
let currentDelay = 0;
|
||||||
|
|
||||||
|
phases.forEach((p, i) => {
|
||||||
|
currentDelay += p.delay;
|
||||||
|
timeouts.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
setProgress(p.progress);
|
||||||
|
setPhase(i);
|
||||||
|
}, currentDelay),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => timeouts.forEach(clearTimeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "#0a0a0f",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 9999,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background grid effect */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
animation: "gridMove 20s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glowing orbs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "300px",
|
||||||
|
height: "300px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)",
|
||||||
|
filter: "blur(40px)",
|
||||||
|
animation: "orbFloat 6s ease-in-out infinite",
|
||||||
|
top: "20%",
|
||||||
|
left: "30%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "250px",
|
||||||
|
height: "250px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)",
|
||||||
|
filter: "blur(40px)",
|
||||||
|
animation: "orbFloat 8s ease-in-out infinite reverse",
|
||||||
|
bottom: "20%",
|
||||||
|
right: "30%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
|
||||||
|
{/* Logo with animation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "16px",
|
||||||
|
marginBottom: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
animation: "logoSpin 3s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaAtom size={48} style={{ color: "#3b82f6" }} />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "42px",
|
||||||
|
fontWeight: 800,
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
letterSpacing: "4px",
|
||||||
|
animation: "titleGlow 2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading icons animation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "24px",
|
||||||
|
marginBottom: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ icon: FaMicrochip, delay: "0s" },
|
||||||
|
{ icon: FaNetworkWired, delay: "0.2s" },
|
||||||
|
{ icon: FaCode, delay: "0.4s" },
|
||||||
|
].map(({ icon: Icon, delay }, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `2px solid ${
|
||||||
|
phase >= i
|
||||||
|
? "rgba(59, 130, 246, 0.6)"
|
||||||
|
: "rgba(255,255,255,0.1)"
|
||||||
|
}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor:
|
||||||
|
phase >= i ? "rgba(59, 130, 246, 0.1)" : "transparent",
|
||||||
|
animation: `iconPop 0.5s ease-out ${delay} both`,
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={22}
|
||||||
|
style={{
|
||||||
|
color: phase >= i ? "#3b82f6" : "#555",
|
||||||
|
transition: "color 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "320px",
|
||||||
|
height: "4px",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${progress}%`,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
boxShadow: "0 0 20px rgba(59, 130, 246, 0.5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
letterSpacing: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phase === 0 && "INITIALIZING CORE..."}
|
||||||
|
{phase === 1 && "LOADING AGENTS..."}
|
||||||
|
{phase === 2 && "ESTABLISHING CONNECTIONS..."}
|
||||||
|
{phase === 3 && "READY"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS Animations */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes gridMove {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
100% { transform: translate(40px, 40px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbFloat {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoSpin {
|
||||||
|
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||||
|
25% { transform: rotate(-10deg) scale(1.05); }
|
||||||
|
75% { transform: rotate(10deg) scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes titleGlow {
|
||||||
|
0%, 100% { filter: brightness(1); }
|
||||||
|
50% { filter: brightness(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iconPop {
|
||||||
|
0% { transform: scale(0.5) translateY(10px); opacity: 0; }
|
||||||
|
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
|
||||||
|
import {
|
||||||
|
Navigation,
|
||||||
|
BottomNav,
|
||||||
|
} from "@/app/providers/layout/navigation/navigation";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
|
||||||
|
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < 856 : false,
|
||||||
|
);
|
||||||
|
const [isVerySmall, setIsVerySmall] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < 600 : false,
|
||||||
|
);
|
||||||
|
const { fetchAgents } = useAgentStore();
|
||||||
|
|
||||||
|
const sidebarOpen = isMobile ? mobileOpen : true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
const mobile = window.innerWidth < 856;
|
||||||
|
setIsMobile(mobile);
|
||||||
|
if (!mobile) {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}
|
||||||
|
setIsVerySmall(window.innerWidth < 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
handleResize();
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-screen overflow-hidden"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onToggle={toggleSidebar}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<Navigation
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
isMobile={isMobile}
|
||||||
|
isVerySmall={isVerySmall}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||||
|
{isVerySmall && <BottomNav />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
|
||||||
|
import {
|
||||||
|
FaHome,
|
||||||
|
FaServer,
|
||||||
|
FaUser,
|
||||||
|
FaUsers,
|
||||||
|
FaRocket,
|
||||||
|
FaKey,
|
||||||
|
FaFileAlt,
|
||||||
|
FaPalette,
|
||||||
|
FaSignOutAlt,
|
||||||
|
FaShieldAlt,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||||
|
import { themes } from "@/modules/theme-changer/config/theme.config";
|
||||||
|
import {
|
||||||
|
applyTheme,
|
||||||
|
getCurrentTheme,
|
||||||
|
} from "@/modules/theme-changer/utils/apply.theme";
|
||||||
|
|
||||||
|
interface NavigationProps {
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
isVerySmall?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Navigation: React.FC<NavigationProps> = ({
|
||||||
|
onToggleSidebar,
|
||||||
|
isMobile,
|
||||||
|
isVerySmall = false,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const { setTheme } = useThemeStore();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentTheme = getCurrentTheme();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
||||||
|
{
|
||||||
|
path: "/add-agents",
|
||||||
|
label: "Деплой",
|
||||||
|
icon: FaRocket,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/registration",
|
||||||
|
label: "Регистрация",
|
||||||
|
icon: FaKey,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
// Filter nav items based on user permissions
|
||||||
|
const filteredNavItems = navItems.filter((item) => {
|
||||||
|
if (item.requireView && !user?.permission_view) return false;
|
||||||
|
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setThemePickerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/auth");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (themeId: string) => {
|
||||||
|
applyTheme(themeId);
|
||||||
|
setTheme(themeId as any);
|
||||||
|
setThemePickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNavItems = (showLabels: boolean, iconSize: number) => (
|
||||||
|
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{filteredNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg font-medium transition-all flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||||
|
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!active) {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!active) {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<Icon size={iconSize} />
|
||||||
|
{showLabels && <span className="text-xs">{item.label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Верхний бар */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-b"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5">
|
||||||
|
{/* Бургер — только на мобильных */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
aria-label="Открыть sidebar"
|
||||||
|
>
|
||||||
|
<FaBars size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Название по центру — только на очень маленьких экранах */}
|
||||||
|
{isVerySmall && (
|
||||||
|
<div className="flex-1 text-center mx-4">
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Навигация — только если НЕ очень маленький экран */}
|
||||||
|
{!isVerySmall && (
|
||||||
|
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
|
||||||
|
{renderNavItems(true, 12)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Профиль пользователя — дропдаун */}
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: dropdownOpen
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{user?.name || user?.login || "Пользователь"}
|
||||||
|
</span>
|
||||||
|
<FaChevronDown
|
||||||
|
size={10}
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
|
||||||
|
transition: "transform 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minWidth: "220px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<FaUser
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{user?.name || user?.login}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{user?.login}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setThemePickerOpen(!themePickerOpen)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPalette
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
Тема: {themes.find((t) => t.id === currentTheme)?.name}
|
||||||
|
</span>
|
||||||
|
<FaChevronDown
|
||||||
|
size={9}
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
transform: themePickerOpen
|
||||||
|
? "rotate(180deg)"
|
||||||
|
: "rotate(0)",
|
||||||
|
transition: "transform 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{themePickerOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minWidth: "180px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{themes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => handleThemeChange(t.id)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentTheme === t.id
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--text-primary)",
|
||||||
|
backgroundColor:
|
||||||
|
currentTheme === t.id
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentTheme !== t.id) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentTheme !== t.id) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.colors.primary,
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.permission_admin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
navigate("/admin");
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
|
||||||
|
<span>Админка</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="my-1 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
|
||||||
|
style={{ color: "var(--error-text)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(239, 68, 68, 0.1)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSignOutAlt size={12} />
|
||||||
|
<span>Выйти</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BottomNav: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
||||||
|
{
|
||||||
|
path: "/add-agents",
|
||||||
|
label: "Деплой",
|
||||||
|
icon: FaRocket,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/registration",
|
||||||
|
label: "Регистрация",
|
||||||
|
icon: FaKey,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
// Filter nav items based on user permissions
|
||||||
|
const filteredNavItems = navItems.filter((item) => {
|
||||||
|
if (item.requireView && !user?.permission_view) return false;
|
||||||
|
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-t"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-around px-2 py-2">
|
||||||
|
{filteredNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className="flex items-center justify-center p-3 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||||
|
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
FaBars,
|
||||||
|
FaMicrochip,
|
||||||
|
FaTimes,
|
||||||
|
FaSpinner,
|
||||||
|
FaCopy,
|
||||||
|
FaCheck,
|
||||||
|
FaChevronRight,
|
||||||
|
FaChevronDown,
|
||||||
|
FaProjectDiagram,
|
||||||
|
FaTrash,
|
||||||
|
FaArrowLeft,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import { adminApi } from "@/modules/admin/api/admin.api";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
isOpen = true,
|
||||||
|
onToggle,
|
||||||
|
isMobile = false,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { agents, isLoading, error, fetchAgents, removeAgent } =
|
||||||
|
useAgentStore();
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||||
|
const [showGraphs, setShowGraphs] = useState(false);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(288);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
|
||||||
|
new Set(agents.map((a) => a.label)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Рассчитываем максимальную ширину при переключении на графы
|
||||||
|
useEffect(() => {
|
||||||
|
const updateWidth = () => {
|
||||||
|
const targetWidth = showGraphs ? 500 : 288;
|
||||||
|
const maxWidth = window.innerWidth - 200;
|
||||||
|
const finalWidth = Math.min(targetWidth, maxWidth);
|
||||||
|
setSidebarWidth(Math.max(finalWidth, 250));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidth();
|
||||||
|
window.addEventListener("resize", updateWidth);
|
||||||
|
return () => window.removeEventListener("resize", updateWidth);
|
||||||
|
}, [showGraphs]);
|
||||||
|
|
||||||
|
// Token generation state
|
||||||
|
const [tokenLabel, setTokenLabel] = useState("");
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
|
const [tokenGenerating, setTokenGenerating] = useState(false);
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleAgent = (label: string) => {
|
||||||
|
setExpandedAgents((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(label)) next.delete(label);
|
||||||
|
else next.add(label);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
if (!searchQuery) return agents;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.label.toLowerCase().includes(query) ||
|
||||||
|
agent.services.some((s) => s.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
|
const [graphData, setGraphData] = useState<GraphData>({
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGraph = () => {
|
||||||
|
agentApiService
|
||||||
|
.getGraph()
|
||||||
|
.then((apiData) => {
|
||||||
|
const nodes: any[] = [];
|
||||||
|
const links: any[] = [];
|
||||||
|
|
||||||
|
// Build a map of service statuses from agents
|
||||||
|
const serviceStatusMap = new Map<string, "up" | "down">();
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
const services = agent.services || [];
|
||||||
|
services.forEach((svc: string) => {
|
||||||
|
const parts = svc.split(":");
|
||||||
|
const svcName = parts[0];
|
||||||
|
const status = parts[1] === "down" ? "down" : "up";
|
||||||
|
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(apiData.nodes || {}).forEach(
|
||||||
|
([agentLabel, agentNode]: [string, any]) => {
|
||||||
|
nodes.push({
|
||||||
|
id: agentLabel,
|
||||||
|
name: agentLabel,
|
||||||
|
type: "agent" as const,
|
||||||
|
val: 8,
|
||||||
|
description: `Агент: ${agentLabel}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = agentNode?.services || {};
|
||||||
|
Object.entries(services).forEach(
|
||||||
|
([serviceName, serviceNode]: [string, any]) => {
|
||||||
|
const serviceId = `${agentLabel}-${serviceName}`;
|
||||||
|
const status = serviceStatusMap.get(serviceId) || "up";
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: serviceId,
|
||||||
|
name: serviceName,
|
||||||
|
type: "service" as const,
|
||||||
|
val: 12,
|
||||||
|
description: `Сервис: ${serviceName}`,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
source: agentLabel,
|
||||||
|
target: serviceId,
|
||||||
|
type: "hosts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dependencies = serviceNode?.dependencies || [];
|
||||||
|
dependencies.forEach((dep: any) => {
|
||||||
|
const targetName = dep?.target?.name;
|
||||||
|
if (targetName) {
|
||||||
|
links.push({
|
||||||
|
source: serviceId,
|
||||||
|
target: `${agentLabel}-${targetName}`,
|
||||||
|
type: dep.condition || "dependency",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setGraphData({ nodes, links });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to fetch graph:", e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGraph();
|
||||||
|
const interval = setInterval(fetchGraph, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const handleCopyToken = () => {
|
||||||
|
const tokenToCopy = generatedToken || token;
|
||||||
|
if (tokenToCopy) {
|
||||||
|
navigator.clipboard.writeText(tokenToCopy);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateToken = async () => {
|
||||||
|
if (!tokenLabel.trim()) return;
|
||||||
|
setTokenGenerating(true);
|
||||||
|
setTokenError(null);
|
||||||
|
try {
|
||||||
|
const newToken = await adminApi.generateToken(tokenLabel.trim());
|
||||||
|
setGeneratedToken(newToken);
|
||||||
|
} catch (e) {
|
||||||
|
setTokenError(
|
||||||
|
e instanceof Error ? e.message : "Failed to generate token",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTokenGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTokenModal = () => {
|
||||||
|
setShowTokenModal(false);
|
||||||
|
setTokenLabel("");
|
||||||
|
setGeneratedToken(null);
|
||||||
|
setTokenError(null);
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay — только на мобильных (< 856px) */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
|
||||||
|
style={{
|
||||||
|
width: `${sidebarWidth}px`,
|
||||||
|
height: "100vh",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Агенты
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agents.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`p-1 rounded transition-colors ${isMobile ? "" : "hidden"}`}
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
aria-label="Закрыть sidebar"
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент — либо список агентов, либо графы */}
|
||||||
|
{showGraphs ? (
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
<Graph initialData={graphData} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск агентов..."
|
||||||
|
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список агентов */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{isLoading && agents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FaSpinner
|
||||||
|
className="animate-spin mb-3"
|
||||||
|
style={{ color: "var(--accent)", fontSize: "20px" }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Загрузка агентов...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div
|
||||||
|
className="text-xs mb-2"
|
||||||
|
style={{ color: "var(--error-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchAgents}
|
||||||
|
className="text-xs hover:underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredAgents.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="text-center py-8"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
|
||||||
|
<p className="text-xs">
|
||||||
|
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredAgents.map((agent) => {
|
||||||
|
const isExpanded = expandedAgents.has(agent.label);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.label}
|
||||||
|
className="rounded-lg border overflow-hidden transition-all group"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Agent header — кликабельный для сворачивания */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => toggleAgent(agent.label)}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronDown size={10} />
|
||||||
|
) : (
|
||||||
|
<FaChevronRight size={10} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<FaMicrochip
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium flex-1 truncate cursor-pointer"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/dashboard/${agent.label}`);
|
||||||
|
}}
|
||||||
|
title="Открыть дашборд агента"
|
||||||
|
>
|
||||||
|
{agent.label}
|
||||||
|
</span>
|
||||||
|
{/* Статус-индикатор агента (количество сервисов) */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{agent.services.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: "#4ade80" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{agent.services.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Кнопка удаления — появляется при наведении */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Удалить агента "${agent.label}"?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
removeAgent(agent.label);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#f87171";
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(248, 113, 113, 0.15)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--text-muted)";
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"transparent";
|
||||||
|
}}
|
||||||
|
title="Удалить агента"
|
||||||
|
>
|
||||||
|
<FaTrash size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services list — сворачивается */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
className="px-3 pb-2"
|
||||||
|
style={{ paddingLeft: "24px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="border-l-2 pl-3 space-y-1"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
{agent.services.map((service) => {
|
||||||
|
// Parse "serviceName:up" or "serviceName:down"
|
||||||
|
const parts = service.split(":");
|
||||||
|
const serviceName = parts[0];
|
||||||
|
const isDown = parts[1] === "down";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={service}
|
||||||
|
className="flex items-center justify-between py-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
color: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "#4ade80",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
color: isDown ? "#ef4444" : "#4ade80",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDown ? "down" : "run"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer с кнопками */}
|
||||||
|
<div
|
||||||
|
className="p-2 border-t flex gap-2"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showGraphs ? (
|
||||||
|
/* Кнопка назад к агентам */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(false)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={10} />К агентам
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
/* Кнопка Графы */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(true)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaProjectDiagram size={10} />
|
||||||
|
Графы
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTokenModal(true)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaCopy size={10} />
|
||||||
|
Токен
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Modal токена */}
|
||||||
|
{showTokenModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
onClick={handleCloseTokenModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl shadow-2xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaCopy style={{ color: "var(--accent)" }} size={14} />
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Генерация токена
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseTokenModal}
|
||||||
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Error */}
|
||||||
|
{tokenError && (
|
||||||
|
<div
|
||||||
|
className="text-xs p-2 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(239,68,68,0.1)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
color: "var(--error-text, #ef4444)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tokenError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label input */}
|
||||||
|
{!generatedToken && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium mb-2"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Имя токена
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tokenLabel}
|
||||||
|
onChange={(e) => setTokenLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && tokenLabel.trim()) {
|
||||||
|
handleGenerateToken();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Введите имя..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generated token */}
|
||||||
|
{generatedToken && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium mb-2"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Токен
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-lg p-3 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className="flex-1 text-xs font-mono break-all"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{generatedToken}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
className="p-1.5 rounded transition-colors"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<FaCheck
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--success-text)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaCopy size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{generatedToken && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setGeneratedToken(null);
|
||||||
|
setTokenLabel("");
|
||||||
|
}}
|
||||||
|
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Новый токен
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={
|
||||||
|
generatedToken ? handleCloseTokenModal : handleGenerateToken
|
||||||
|
}
|
||||||
|
disabled={tokenGenerating || !tokenLabel.trim()}
|
||||||
|
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color:
|
||||||
|
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||||
|
? "var(--text-muted)"
|
||||||
|
: "var(--accent-text)",
|
||||||
|
cursor:
|
||||||
|
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||||
|
? "default"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tokenGenerating
|
||||||
|
? "Генерация..."
|
||||||
|
: generatedToken
|
||||||
|
? "Готово"
|
||||||
|
: "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import type { AgentInfo } from "@/modules/agent/types/agent.types";
|
||||||
|
|
||||||
|
interface AgentState {
|
||||||
|
agents: AgentInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchAgents: () => Promise<void>;
|
||||||
|
removeAgent: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAgentStore = create<AgentState>()((set, get) => ({
|
||||||
|
agents: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchAgents: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const agents = await agentApiService.getAgents();
|
||||||
|
set({ agents, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to fetch agents",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAgent: (name: string) => {
|
||||||
|
set({ agents: get().agents.filter((a) => a.label !== name) });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import type { SystemMetrics } from "@/modules/agent/types/agent.types";
|
||||||
|
|
||||||
|
interface MetricsState {
|
||||||
|
metrics: SystemMetrics[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 30_000;
|
||||||
|
|
||||||
|
let _pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export const useMetricsStore = create<MetricsState>(() => ({
|
||||||
|
metrics: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const startMetricsPolling = async () => {
|
||||||
|
if (_pollingTimer) return;
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
try {
|
||||||
|
const data = await agentApiService.getSystemMetrics();
|
||||||
|
useMetricsStore.setState({
|
||||||
|
metrics: data,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
useMetricsStore.setState({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to fetch metrics",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await fetchMetrics();
|
||||||
|
_pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopMetricsPolling = () => {
|
||||||
|
if (_pollingTimer) {
|
||||||
|
clearInterval(_pollingTimer);
|
||||||
|
_pollingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,12 +1,42 @@
|
|||||||
import { useAuthStore } from "@/store/auth/auth.store";
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
interface ProtectedRouteProps {
|
||||||
const { isAuthenticated } = useAuthStore();
|
children: React.ReactNode;
|
||||||
|
requireView?: boolean;
|
||||||
|
requireManageAgent?: boolean;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
fallbackPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
|
children,
|
||||||
|
requireView = false,
|
||||||
|
requireManageAgent = false,
|
||||||
|
requireAdmin = false,
|
||||||
|
fallbackPath = "/",
|
||||||
|
}) => {
|
||||||
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (!isAuthenticated && user?.token) {
|
||||||
|
// User is authenticated based on token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requireView && !user.permission_view) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireManageAgent && !user.permission_manage_agent) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && !user.permission_admin) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,113 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
||||||
import { HomePage } from "@/pages/home.page";
|
import { HomePage } from "@/pages/home.page";
|
||||||
import { ThemesPage } from "@/pages/themes.page";
|
import { TestPage } from "@/pages/test.page";
|
||||||
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
import { AuthPage } from "@/pages/auth.page";
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
import { RegisterPage } from "@/pages/register.page";
|
||||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||||
|
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||||
|
import { IDEPage } from "@/pages/ide.page";
|
||||||
|
import { TemplatesPage } from "@/pages/templates.page";
|
||||||
|
import { AdminPage } from "@/pages/admin.page";
|
||||||
|
import { RegistrationTokenPage } from "@/pages/registration.page";
|
||||||
|
import { LogsPage } from "@/pages/logs.page";
|
||||||
|
import { GraphsPage } from "@/pages/graphs.page";
|
||||||
|
import { DashboardPage } from "@/pages/dashboard.page";
|
||||||
|
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
|
||||||
|
import { ProtectedRoute } from "./helper/protected.route";
|
||||||
|
|
||||||
|
export const mockGraphData: GraphData = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "api-gateway",
|
||||||
|
name: "API Gateway",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Входная точка API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auth-service",
|
||||||
|
name: "Auth Service",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Аутентификация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "db-service",
|
||||||
|
name: "Database",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Хранилище данных",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redis-service",
|
||||||
|
name: "Redis",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Кэширование",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queue-service",
|
||||||
|
name: "Message Queue",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Очередь сообщений",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-agent",
|
||||||
|
name: "User Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Обработка пользователей",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payment-agent",
|
||||||
|
name: "Payment Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Платежи",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-agent",
|
||||||
|
name: "Notification Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Уведомления",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analytics-agent",
|
||||||
|
name: "Analytics Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Аналитика",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "report-agent",
|
||||||
|
name: "Report Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Отчеты",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: "user-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "analytics-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
|
||||||
|
{ source: "auth-service", target: "db-service", type: "depends_on" },
|
||||||
|
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
|
||||||
|
{ source: "queue-service", target: "redis-service", type: "depends_on" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const Routing = () => {
|
export const Routing = () => {
|
||||||
return (
|
return (
|
||||||
@@ -17,15 +119,94 @@ export const Routing = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
<Route element={<DefaultLayout />}>
|
|
||||||
<Route path="/" element={<HomePage />} />
|
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
|
||||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
<Route element={<DefaultLayout />}>
|
||||||
|
{/* Routes requiring 'view' permission */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<TemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/logs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<LogsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/graphs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<GraphsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/:agentLabel"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<AgentDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Routes requiring 'manage_agent' permission */}
|
||||||
|
<Route
|
||||||
|
path="/add-agents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireManageAgent>
|
||||||
|
<AddAgentsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/registration"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireManageAgent>
|
||||||
|
<RegistrationTokenPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<TemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/IDE"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<IDEPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin route requiring 'admin' permission */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/test" element={<TestPage />} />
|
||||||
|
|
||||||
|
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
FaUsers,
|
||||||
|
FaShieldAlt,
|
||||||
|
FaSpinner,
|
||||||
|
FaExclamationCircle,
|
||||||
|
FaPlus,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useAdminStore } from "./store/useAdminStore";
|
||||||
|
import { UserCard } from "./components/UserCard";
|
||||||
|
import { CreateUserModal } from "./components/CreateUserModal";
|
||||||
|
|
||||||
|
export const AdminPanel: React.FC = () => {
|
||||||
|
const users = useAdminStore((s) => s.users);
|
||||||
|
const loading = useAdminStore((s) => s.loading);
|
||||||
|
const error = useAdminStore((s) => s.error);
|
||||||
|
const fetchUsers = useAdminStore((s) => s.fetchUsers);
|
||||||
|
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeCount = users.filter((u) => u.is_active).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px", maxWidth: "900px", margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaShieldAlt size={18} style={{ color: "var(--accent-text)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Управление пользователями
|
||||||
|
</h1>
|
||||||
|
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{loading
|
||||||
|
? "Загрузка..."
|
||||||
|
: `${activeCount} / ${users.length} активных`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPlus size={12} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "12px",
|
||||||
|
backgroundColor: "rgba(239,68,68,0.1)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "var(--error-text, #ef4444)",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaExclamationCircle />
|
||||||
|
<span style={{ fontSize: "13px" }}>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && users.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "60px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSpinner
|
||||||
|
className="animate-spin"
|
||||||
|
size={24}
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users list */}
|
||||||
|
{!loading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((user) => (
|
||||||
|
<UserCard key={user.id} user={user} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && users.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "40px 0",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: "14px" }}>
|
||||||
|
Нет зарегистрированных пользователей
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create user modal */}
|
||||||
|
<CreateUserModal
|
||||||
|
isOpen={showCreateModal}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const raw = localStorage.getItem("auth-storage");
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.state?.token) return `bearer ${parsed.state.token}`;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AdminUserDto {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserPayload {
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
password: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionsPayload {
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getUsers: async (): Promise<AdminUserDto[]> => {
|
||||||
|
const res = await apiClient.get<AdminUserDto[]>("/auth/tokens", {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (payload: CreateUserPayload): Promise<void> => {
|
||||||
|
await apiClient.post("/auth/token", payload, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (login: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/auth/tokens/${login}`, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
activateUser: async (login: string): Promise<void> => {
|
||||||
|
await apiClient.post(
|
||||||
|
`/auth/users/${login}/activate`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: getAuthHeader() } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivateUser: async (login: string): Promise<void> => {
|
||||||
|
await apiClient.post(
|
||||||
|
`/auth/users/${login}/deactivate`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: getAuthHeader() } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePermissions: async (
|
||||||
|
login: string,
|
||||||
|
payload: PermissionsPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/auth/users/${login}/permissions`, payload, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateToken: async (label: string): Promise<string> => {
|
||||||
|
const res = await apiClient.post<{ token: string }>(
|
||||||
|
"/agents/register-token",
|
||||||
|
{ label },
|
||||||
|
{ headers: { Authorization: getAuthHeader() } },
|
||||||
|
);
|
||||||
|
return res.data.token;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FaTimes, FaPlus } from "react-icons/fa";
|
||||||
|
import { useAdminStore } from "../store/useAdminStore";
|
||||||
|
|
||||||
|
interface CreateUserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const createUser = useAdminStore((s) => s.createUser);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
login: "",
|
||||||
|
name: "",
|
||||||
|
last_name: "",
|
||||||
|
password: "",
|
||||||
|
is_active: true,
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.login || !form.password) return;
|
||||||
|
setLoading(true);
|
||||||
|
await createUser(form);
|
||||||
|
setLoading(false);
|
||||||
|
setForm({
|
||||||
|
login: "",
|
||||||
|
name: "",
|
||||||
|
last_name: "",
|
||||||
|
password: "",
|
||||||
|
is_active: true,
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: true,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
minWidth: "380px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать пользователя
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
{/* Login */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.login}
|
||||||
|
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + Last name */}
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Имя
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Фамилия
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.last_name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, last_name: e.target.value })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div style={{ paddingTop: "8px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Разрешения
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
|
||||||
|
{[
|
||||||
|
{ key: "is_active", label: "Active" },
|
||||||
|
{ key: "permission_view", label: "View" },
|
||||||
|
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||||
|
{ key: "permission_admin", label: "Admin" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={
|
||||||
|
form[key as keyof typeof form] as boolean
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, [key]: e.target.checked })
|
||||||
|
}
|
||||||
|
style={{ accentColor: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !form.login || !form.password}
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor:
|
||||||
|
loading || !form.login || !form.password
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color:
|
||||||
|
loading || !form.login || !form.password
|
||||||
|
? "var(--text-muted)"
|
||||||
|
: "var(--accent-text)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor:
|
||||||
|
loading || !form.login || !form.password
|
||||||
|
? "default"
|
||||||
|
: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPlus size={12} />
|
||||||
|
{loading ? "Создание..." : "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
|
||||||
|
import type { AdminUser, PermissionKey } from "../types";
|
||||||
|
import { useAdminStore } from "../store/useAdminStore";
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: AdminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: { key: PermissionKey; label: string }[] = [
|
||||||
|
{ key: "permission_view", label: "View" },
|
||||||
|
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||||
|
{ key: "permission_admin", label: "Admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||||
|
const users = useAdminStore((s) => s.users);
|
||||||
|
const toggleActive = useAdminStore((s) => s.toggleActive);
|
||||||
|
const togglePermission = useAdminStore((s) => s.togglePermission);
|
||||||
|
const deleteUser = useAdminStore((s) => s.deleteUser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
opacity: user.is_active ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header: User info + Active toggle + Delete */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: user.is_active
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaUser size={16} style={{ color: "var(--card-bg)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name} {user.last_name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.login}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
{/* Active toggle */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
color: user.is_active
|
||||||
|
? "var(--success-text, #22c55e)"
|
||||||
|
: "var(--error-text, #ef4444)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.is_active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(user.id, user.login, user.is_active)}
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "22px",
|
||||||
|
borderRadius: "11px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
|
||||||
|
cursor: "pointer",
|
||||||
|
position: "relative",
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
position: "absolute",
|
||||||
|
top: "3px",
|
||||||
|
left: user.is_active ? "21px" : "3px",
|
||||||
|
transition: "left 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Удалить пользователя "${user.login}"?`)) {
|
||||||
|
deleteUser(user.id, user.login);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Удалить"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "6px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--error-text, #ef4444)";
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(239,68,68,0.1)";
|
||||||
|
e.currentTarget.style.borderColor = "rgba(239,68,68,0.3)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--text-muted)";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrash size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "16px",
|
||||||
|
paddingTop: "12px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permissions.map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => togglePermission(user.id, user.login, key, users)}
|
||||||
|
style={{
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: user[key] ? "var(--accent)" : "var(--border)",
|
||||||
|
backgroundColor: user[key] ? "var(--accent)" : "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user[key] && (
|
||||||
|
<FaCheck
|
||||||
|
size={10}
|
||||||
|
style={{ color: "var(--accent-text, #fff)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { AdminPanel } from "./AdminPanel";
|
||||||
|
export { useAdminStore } from "./store/useAdminStore";
|
||||||
|
export { adminApi } from "./api/admin.api";
|
||||||
|
export type { AdminUser } from "./types";
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { AdminUser, PermissionKey } from "../types";
|
||||||
|
import { adminApi } from "../api/admin.api";
|
||||||
|
import type { CreateUserPayload } from "../api/admin.api";
|
||||||
|
|
||||||
|
interface AdminState {
|
||||||
|
users: AdminUser[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchUsers: () => Promise<void>;
|
||||||
|
createUser: (payload: CreateUserPayload) => Promise<void>;
|
||||||
|
deleteUser: (id: string, login: string) => Promise<void>;
|
||||||
|
toggleActive: (id: string, login: string, current: boolean) => Promise<void>;
|
||||||
|
togglePermission: (
|
||||||
|
id: string,
|
||||||
|
login: string,
|
||||||
|
permission: PermissionKey,
|
||||||
|
users: AdminUser[],
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdminStore = create<AdminState>((set, get) => ({
|
||||||
|
users: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchUsers: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getUsers();
|
||||||
|
set({
|
||||||
|
users: data.map((u) => ({
|
||||||
|
id: String(u.id),
|
||||||
|
login: u.login,
|
||||||
|
name: u.name,
|
||||||
|
last_name: u.last_name,
|
||||||
|
is_active: u.is_active,
|
||||||
|
permission_admin: u.permission_admin,
|
||||||
|
permission_manage_agent: u.permission_manage_agent,
|
||||||
|
permission_view: u.permission_view,
|
||||||
|
})),
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
set({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to fetch users",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (payload) => {
|
||||||
|
try {
|
||||||
|
await adminApi.createUser(payload);
|
||||||
|
await get().fetchUsers();
|
||||||
|
} catch (e) {
|
||||||
|
set({ error: e instanceof Error ? e.message : "Failed to create user" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (id, login) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUser(login);
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.filter((u) => u.id !== id),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set({ error: e instanceof Error ? e.message : "Failed to delete user" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleActive: async (id, login, current) => {
|
||||||
|
try {
|
||||||
|
if (current) {
|
||||||
|
await adminApi.deactivateUser(login);
|
||||||
|
} else {
|
||||||
|
await adminApi.activateUser(login);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.map((u) =>
|
||||||
|
u.id === id ? { ...u, is_active: !current } : u,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to toggle active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePermission: async (id, login, permission, users) => {
|
||||||
|
const user = users.find((u) => u.id === id);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const newPermissions = {
|
||||||
|
is_active: user.is_active,
|
||||||
|
permission_admin:
|
||||||
|
permission === "permission_admin"
|
||||||
|
? !user.permission_admin
|
||||||
|
: user.permission_admin,
|
||||||
|
permission_manage_agent:
|
||||||
|
permission === "permission_manage_agent"
|
||||||
|
? !user.permission_manage_agent
|
||||||
|
: user.permission_manage_agent,
|
||||||
|
permission_view:
|
||||||
|
permission === "permission_view"
|
||||||
|
? !user.permission_view
|
||||||
|
: user.permission_view,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.updatePermissions(login, newPermissions);
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.map((u) =>
|
||||||
|
u.id === id
|
||||||
|
? {
|
||||||
|
...u,
|
||||||
|
[permission]: !u[permission],
|
||||||
|
}
|
||||||
|
: u,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to update permissions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionKey =
|
||||||
|
| "permission_admin"
|
||||||
|
| "permission_manage_agent"
|
||||||
|
| "permission_view";
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
import type {
|
||||||
|
AgentInfo,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
LogFilters,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
DeployResponse,
|
||||||
|
SystemMetrics,
|
||||||
|
} from "../types/agent.types";
|
||||||
|
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||||
|
|
||||||
|
class AgentApiService {
|
||||||
|
private readonly basePath = "/agents";
|
||||||
|
private readonly authBasePath = "/auth";
|
||||||
|
private readonly logsBasePath = "/logs";
|
||||||
|
|
||||||
|
async getAgents(): Promise<AgentInfo[]> {
|
||||||
|
const response = await apiClient.get<AgentInfo[]>(this.basePath);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/tokens`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: TokenCreate): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/token`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(login: string): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyAccount(): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.authBasePath}/token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
|
||||||
|
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
|
||||||
|
params: {
|
||||||
|
level: filters?.level || undefined,
|
||||||
|
service: filters?.service || undefined,
|
||||||
|
agent: filters?.agent || undefined,
|
||||||
|
date_from: filters?.date_from || undefined,
|
||||||
|
date_to: filters?.date_to || undefined,
|
||||||
|
limit: filters?.limit ?? 100,
|
||||||
|
offset: filters?.offset ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(response.data)) {
|
||||||
|
console.error(
|
||||||
|
"[Logs] Unexpected response format:",
|
||||||
|
typeof response.data,
|
||||||
|
response.data,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertLog(entry: InsertLogRequest): Promise<void> {
|
||||||
|
await apiClient.post(this.logsBasePath, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
|
||||||
|
await apiClient.post(`${this.logsBasePath}/batch`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctAgents(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/agents`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctLevels(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/levels`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctServices(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/services`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// User management methods
|
||||||
|
async getUserByLogin(login: string): Promise<TokenUser> {
|
||||||
|
const response = await apiClient.get<TokenUser>(
|
||||||
|
`${this.authBasePath}/users/${login}`,
|
||||||
|
);
|
||||||
|
if (!response.data || typeof response.data !== "object") {
|
||||||
|
throw new Error(`User not found: ${login}`);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInactiveUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/users/inactive`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(login: string, data: TokenUpdate): Promise<void> {
|
||||||
|
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserPermissions(
|
||||||
|
login: string,
|
||||||
|
data: TokenUpdatePermissions,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.put(
|
||||||
|
`${this.authBasePath}/users/${login}/permissions`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUserPassword(
|
||||||
|
login: string,
|
||||||
|
data: TokenPasswordReset,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateUser(login: string): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateUser(login: string): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegistrationToken(
|
||||||
|
data: RegistrationRequest,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const response = await apiClient.post<Record<string, string>>(
|
||||||
|
`${this.basePath}/register-token`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
|
||||||
|
const response = await apiClient.post<DeployResponse>(
|
||||||
|
`${this.basePath}/deploy`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemMetrics(): Promise<SystemMetrics[]> {
|
||||||
|
const response = await apiClient.get<SystemMetrics[]>(
|
||||||
|
`${this.basePath}/system-metrics`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGraph(): Promise<GraphApiResponse> {
|
||||||
|
const response = await apiClient.get<GraphApiResponse>("/graph");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentApiService = new AgentApiService();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { agentApiService } from "../api/agent.api.service";
|
||||||
|
import type { AgentInfo } from "../types/agent.types";
|
||||||
|
|
||||||
|
interface UseAgentsResult {
|
||||||
|
agents: AgentInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgents(): UseAgentsResult {
|
||||||
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAgents = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await agentApiService.getAgents();
|
||||||
|
setAgents(data);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch agents";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
return { agents, isLoading, error, refetch: fetchAgents };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export { SSHAgentForm } from "./ui/SSHAgentForm";
|
||||||
|
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
|
||||||
|
|
||||||
|
export { useAgents } from "./hooks/useAgents.hook";
|
||||||
|
|
||||||
|
export { agentApiService } from "./api/agent.api.service";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AgentInfo,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
LogFilters,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployResult,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
AgentDeployConfig,
|
||||||
|
DeployResponse,
|
||||||
|
} from "./types/agent.types";
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type LogLevel = "info" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
|
interface LogFilterState {
|
||||||
|
searchQuery: string;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
selectedLogLevel: LogLevel | null;
|
||||||
|
selectedService: string;
|
||||||
|
selectedAgent: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setStartDate: (date: Date | null) => void;
|
||||||
|
setEndDate: (date: Date | null) => void;
|
||||||
|
setSelectedLogLevel: (level: LogLevel | null) => void;
|
||||||
|
setSelectedService: (service: string) => void;
|
||||||
|
setSelectedAgent: (agent: string) => void;
|
||||||
|
setLimit: (limit: number) => void;
|
||||||
|
setOffset: (offset: number) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
getFilters: () => {
|
||||||
|
level?: string;
|
||||||
|
service?: string;
|
||||||
|
agent?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||||
|
searchQuery: "",
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
selectedLogLevel: null,
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
setStartDate: (date) => set({ startDate: date }),
|
||||||
|
setEndDate: (date) => set({ endDate: date }),
|
||||||
|
setSelectedLogLevel: (level) => set({ selectedLogLevel: level }),
|
||||||
|
setSelectedService: (service) => set({ selectedService: service }),
|
||||||
|
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||||
|
setLimit: (limit) => set({ limit }),
|
||||||
|
setOffset: (offset) => set({ offset }),
|
||||||
|
|
||||||
|
resetFilters: () => {
|
||||||
|
set({
|
||||||
|
searchQuery: "",
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
selectedLogLevel: null,
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilters: () => {
|
||||||
|
const {
|
||||||
|
selectedLogLevel,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = get();
|
||||||
|
return {
|
||||||
|
level: selectedLogLevel || undefined,
|
||||||
|
service: selectedService || undefined,
|
||||||
|
agent: selectedAgent || undefined,
|
||||||
|
date_from: startDate ? startDate.toISOString() : undefined,
|
||||||
|
date_to: endDate ? endDate.toISOString() : undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
export interface AgentInfo {
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
services: string[];
|
||||||
|
connected_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
last_name: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenCreate {
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
password: string;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
Agent: string;
|
||||||
|
Level: string;
|
||||||
|
Message: string;
|
||||||
|
Service: string;
|
||||||
|
Timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertLogRequest {
|
||||||
|
agent: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
service: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertLogsRequest {
|
||||||
|
logs: InsertLogRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilters {
|
||||||
|
level?: string | string[];
|
||||||
|
service?: string;
|
||||||
|
agent?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUpdate {
|
||||||
|
name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUpdatePermissions {
|
||||||
|
is_active?: boolean;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPasswordReset {
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationRequest {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployResult {
|
||||||
|
agent_label: string;
|
||||||
|
error?: string;
|
||||||
|
ip: string;
|
||||||
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployAgentsRequest {
|
||||||
|
servers: AgentDeployConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDeployConfig {
|
||||||
|
agentLabel: string;
|
||||||
|
authMethod: "key" | "password";
|
||||||
|
deployType: "docker" | "binary";
|
||||||
|
ip: string;
|
||||||
|
password?: string;
|
||||||
|
port?: number;
|
||||||
|
sshKey?: string;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployResponse {
|
||||||
|
message?: string;
|
||||||
|
results: DeployResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemMetrics {
|
||||||
|
connected_at: string;
|
||||||
|
cpu_percent: number;
|
||||||
|
disk_percent: number;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
memory_percent: number;
|
||||||
|
network_rx_bytes: number;
|
||||||
|
network_tx_bytes: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
FiSearch,
|
||||||
|
FiX,
|
||||||
|
FiFilter,
|
||||||
|
FiCalendar,
|
||||||
|
FiTag,
|
||||||
|
FiCheck,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
|
||||||
|
|
||||||
|
const logLevelColors: Record<
|
||||||
|
LogLevel,
|
||||||
|
{ bg: string; text: string; border: string }
|
||||||
|
> = {
|
||||||
|
info: {
|
||||||
|
bg: "rgba(59, 130, 246, 0.1)",
|
||||||
|
text: "#3b82f6",
|
||||||
|
border: "rgba(59, 130, 246, 0.3)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "rgba(245, 158, 11, 0.1)",
|
||||||
|
text: "#f59e0b",
|
||||||
|
border: "rgba(245, 158, 11, 0.3)",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "var(--error-bg)",
|
||||||
|
text: "var(--error-text)",
|
||||||
|
border: "var(--error-border)",
|
||||||
|
},
|
||||||
|
fatal: {
|
||||||
|
bg: "rgba(168, 85, 247, 0.1)",
|
||||||
|
text: "#a855f7",
|
||||||
|
border: "rgba(168, 85, 247, 0.3)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogFiltersProps {
|
||||||
|
onApply: () => void;
|
||||||
|
availableServices: string[];
|
||||||
|
availableAgents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||||
|
onApply,
|
||||||
|
availableServices,
|
||||||
|
availableAgents,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedLogLevel,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
setSearchQuery,
|
||||||
|
setStartDate,
|
||||||
|
setEndDate,
|
||||||
|
setSelectedLogLevel,
|
||||||
|
setSelectedService,
|
||||||
|
setSelectedAgent,
|
||||||
|
resetFilters,
|
||||||
|
} = useLogFilterStore();
|
||||||
|
|
||||||
|
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||||
|
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
|
||||||
|
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
|
||||||
|
const [localService, setLocalService] = useState(selectedService);
|
||||||
|
const [localAgent, setLocalAgent] = useState(selectedAgent);
|
||||||
|
const [localLevel, setLocalLevel] = useState<LogLevel | null>(
|
||||||
|
selectedLogLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSearchQuery(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStartDate(startDate);
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalEndDate(endDate);
|
||||||
|
}, [endDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalService(selectedService);
|
||||||
|
}, [selectedService]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalAgent(selectedAgent);
|
||||||
|
}, [selectedAgent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalLevel(selectedLogLevel);
|
||||||
|
}, [selectedLogLevel]);
|
||||||
|
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
setSearchQuery(localSearchQuery);
|
||||||
|
setStartDate(localStartDate);
|
||||||
|
setEndDate(localEndDate);
|
||||||
|
setSelectedLogLevel(localLevel);
|
||||||
|
setSelectedService(localService);
|
||||||
|
setSelectedAgent(localAgent);
|
||||||
|
onApply();
|
||||||
|
}, [
|
||||||
|
localSearchQuery,
|
||||||
|
localStartDate,
|
||||||
|
localEndDate,
|
||||||
|
localLevel,
|
||||||
|
localService,
|
||||||
|
localAgent,
|
||||||
|
onApply,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setLocalEndDate(null);
|
||||||
|
setLocalLevel(null);
|
||||||
|
setLocalService("");
|
||||||
|
setLocalAgent("");
|
||||||
|
resetFilters();
|
||||||
|
onApply();
|
||||||
|
}, [resetFilters, onApply]);
|
||||||
|
|
||||||
|
const getActiveFiltersCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
if (searchQuery) count++;
|
||||||
|
if (startDate) count++;
|
||||||
|
if (endDate) count++;
|
||||||
|
if (selectedService) count++;
|
||||||
|
if (selectedAgent) count++;
|
||||||
|
if (selectedLogLevel) count++;
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return null;
|
||||||
|
return date.toLocaleDateString("ru-RU");
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFiltersCount = getActiveFiltersCount();
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiFilter size={14} style={{ color: "var(--accent)" }} />
|
||||||
|
<h3
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Фильтры логов
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Активно: {activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<FiSearch
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "10px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSearchQuery}
|
||||||
|
onChange={(e) => setLocalSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск по сообщению..."
|
||||||
|
style={{ ...inputStyle, paddingLeft: "32px" }}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Select */}
|
||||||
|
<select
|
||||||
|
value={localService}
|
||||||
|
onChange={(e) => setLocalService(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">Все сервисы</option>
|
||||||
|
{availableServices.map((service) => (
|
||||||
|
<option key={service} value={service}>
|
||||||
|
{service}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Agent Select */}
|
||||||
|
<select
|
||||||
|
value={localAgent}
|
||||||
|
onChange={(e) => setLocalAgent(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">Все агенты</option>
|
||||||
|
{availableAgents.map((agent) => (
|
||||||
|
<option key={agent} value={agent}>
|
||||||
|
{agent}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date Range */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={
|
||||||
|
localStartDate ? localStartDate.toISOString().split("T")[0] : ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalStartDate(
|
||||||
|
e.target.value ? new Date(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, minWidth: 0 }}
|
||||||
|
placeholder="Дата от"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={
|
||||||
|
localEndDate ? localEndDate.toISOString().split("T")[0] : ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalEndDate(
|
||||||
|
e.target.value ? new Date(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, minWidth: 0 }}
|
||||||
|
placeholder="Дата до"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Levels */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Уровень логов
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
|
||||||
|
(level) => {
|
||||||
|
const isSelected = localLevel === level;
|
||||||
|
const colors = logLevelColors[level];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => setLocalLevel(isSelected ? null : level)}
|
||||||
|
className="px-3 py-2 rounded-lg text-xs font-medium transition-all border flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? colors.bg : "transparent",
|
||||||
|
color: isSelected ? colors.text : "var(--text-secondary)",
|
||||||
|
borderColor: isSelected ? colors.border : "var(--border)",
|
||||||
|
minHeight: "36px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.text;
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
} else {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(128, 128, 128, 0.08)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isSelected
|
||||||
|
? colors.bg
|
||||||
|
: "transparent";
|
||||||
|
e.currentTarget.style.color = isSelected
|
||||||
|
? colors.text
|
||||||
|
: "var(--text-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<FiCheck size={10} className="inline mr-1" />
|
||||||
|
)}
|
||||||
|
{level.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
minHeight: "44px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCheck size={14} />
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minHeight: "44px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={14} />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-4 pt-4 border-t"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiFilter size={10} style={{ color: "var(--accent)" }} />
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Активные фильтры:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{searchQuery && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSearch size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Поиск: {searchQuery}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setSearchQuery("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedService && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Сервис: {selectedService}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalService("");
|
||||||
|
setSelectedService("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLogLevel &&
|
||||||
|
(() => {
|
||||||
|
const colors = logLevelColors[selectedLogLevel];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} style={{ color: colors.text }} />
|
||||||
|
<span style={{ color: colors.text }}>
|
||||||
|
Уровень: {selectedLogLevel.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalLevel(null);
|
||||||
|
setSelectedLogLevel(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{selectedAgent && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Агент: {selectedAgent}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalAgent("");
|
||||||
|
setSelectedAgent("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{startDate && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCalendar size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
С: {formatDate(startDate)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setStartDate(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endDate && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCalendar size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
По: {formatDate(endDate)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalEndDate(null);
|
||||||
|
setEndDate(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FiPlus,
|
FiPlus,
|
||||||
FiTrash2,
|
FiTrash2,
|
||||||
FiSettings,
|
FiSettings,
|
||||||
|
FiLink,
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { SiDocker } from "react-icons/si";
|
import { SiDocker } from "react-icons/si";
|
||||||
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
||||||
@@ -20,8 +21,10 @@ interface ExtraField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHAgentConfig {
|
export interface SSHAgentConfig {
|
||||||
|
agentLabel: string;
|
||||||
user: string;
|
user: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
port: number;
|
||||||
authMethod: AuthMethod;
|
authMethod: AuthMethod;
|
||||||
sshKey?: string;
|
sshKey?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -189,11 +192,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "20px" }}>
|
<div style={{ display: "grid", gap: "20px" }}>
|
||||||
{/* User и IP */}
|
{/* Agent Label */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<FiServer size={14} />
|
||||||
|
Метка агента *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.agentLabel}
|
||||||
|
onChange={(e) => handleChange("agentLabel", e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="production-server-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User, IP и Port */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -238,6 +261,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
placeholder="192.168.1.1"
|
placeholder="192.168.1.1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiLink size={14} />
|
||||||
|
Порт *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("port", parseInt(e.target.value) || 22)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Метод аутентификации */}
|
{/* Метод аутентификации */}
|
||||||
@@ -457,7 +505,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (data: RegisterData): Promise<LoginResponse> => {
|
const register = async (
|
||||||
const response = await apiClient.post<LoginResponse>("/auth/register", {
|
data: RegisterData,
|
||||||
|
): Promise<Record<string, string>> => {
|
||||||
|
const response = await apiClient.post<Record<string, string>>(
|
||||||
|
"/auth/register",
|
||||||
|
{
|
||||||
login: data.login,
|
login: data.login,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
name: data.firstName,
|
name: data.firstName,
|
||||||
last_name: data.lastName,
|
last_name: data.lastName,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,9 +67,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
register: async (data: RegisterData) => {
|
register: async (data: RegisterData) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await register(data);
|
await register(data);
|
||||||
const user = mapResponseToUser(response);
|
// После регистрации пользователь не авторизуется автоматически
|
||||||
set({ user, token: response.token, isLoading: false });
|
// Нужно войти через /auth/login
|
||||||
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaPlus } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface AddWidgetButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddWidgetButton: React.FC<AddWidgetButtonProps> = ({
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full py-1.5 bg-tertiary hover:bg-tertiary/70 rounded-lg border border-primary transition-colors flex items-center justify-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<FaPlus size={10} className="text-tertiary" />
|
||||||
|
<span className="text-[10px] text-secondary">Добавить график</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import type { ChartType } from "../types";
|
||||||
|
|
||||||
|
interface AddWidgetModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onAdd: (data: { type: ChartType; title: string; dataKey: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddWidgetModal: React.FC<AddWidgetModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [type, setType] = useState<ChartType>("line");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [dataKey, setDataKey] = useState("requests");
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
onAdd({ type, title: title.trim(), dataKey });
|
||||||
|
setTitle("");
|
||||||
|
setType("line");
|
||||||
|
setDataKey("requests");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-primary mb-3">
|
||||||
|
Добавить график
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Тип
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
||||||
|
type === t
|
||||||
|
? "bg-accent-primary text-white"
|
||||||
|
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "line" && "📈"}
|
||||||
|
{t === "bar" && "📊"}
|
||||||
|
{t === "area" && "📉"}
|
||||||
|
{t === "pie" && "🥧"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Название"
|
||||||
|
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// modules/dashboard/components/ChartWidget.tsx
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
FaChartLine,
|
||||||
|
FaChartBar,
|
||||||
|
FaChartArea,
|
||||||
|
FaChartPie,
|
||||||
|
FaCog,
|
||||||
|
FaEye,
|
||||||
|
FaEyeSlash,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartWidget as ChartWidgetType, MetricData } from "../types";
|
||||||
|
|
||||||
|
interface ChartWidgetProps {
|
||||||
|
widget: ChartWidgetType;
|
||||||
|
data: MetricData[];
|
||||||
|
onEdit: () => void;
|
||||||
|
onToggleVisibility: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все возможные уровни логов (метрики)
|
||||||
|
const METRICS = ["INFO", "WARN", "ERROR", "DEBUG"];
|
||||||
|
|
||||||
|
// Цвета для каждой метрики
|
||||||
|
const METRIC_COLORS: Record<string, string> = {
|
||||||
|
INFO: "#10b981", // зеленый
|
||||||
|
WARN: "#f59e0b", // оранжевый
|
||||||
|
ERROR: "#ef4444", // красный
|
||||||
|
DEBUG: "#3b82f6", // синий
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||||
|
widget,
|
||||||
|
data,
|
||||||
|
onEdit,
|
||||||
|
onToggleVisibility,
|
||||||
|
}) => {
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-[10px] text-tertiary">Нет данных</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedData = data.map((point) => {
|
||||||
|
const normalized: MetricData = { timestamp: point.timestamp };
|
||||||
|
METRICS.forEach((metric) => {
|
||||||
|
normalized[metric] = point[metric] || 0;
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
data: normalizedData,
|
||||||
|
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (widget.type) {
|
||||||
|
case "line":
|
||||||
|
return (
|
||||||
|
<LineChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Line
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={METRIC_COLORS[metric]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "bar":
|
||||||
|
return (
|
||||||
|
<BarChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Bar
|
||||||
|
key={metric}
|
||||||
|
dataKey={metric}
|
||||||
|
fill={METRIC_COLORS[metric]}
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "area":
|
||||||
|
return (
|
||||||
|
<AreaChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Area
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={METRIC_COLORS[metric]}
|
||||||
|
fill={METRIC_COLORS[metric]}
|
||||||
|
fillOpacity={0.2}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pie":
|
||||||
|
// Для круговой диаграммы берем последнюю точку
|
||||||
|
const lastPoint = normalizedData[normalizedData.length - 1];
|
||||||
|
const pieData = METRICS.map((metric) => ({
|
||||||
|
name: metric,
|
||||||
|
value: lastPoint[metric] || 0,
|
||||||
|
})).filter((item) => Number(item.value) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={55}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={METRIC_COLORS[entry.name]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (widget.type) {
|
||||||
|
case "line":
|
||||||
|
return <FaChartLine size={10} />;
|
||||||
|
case "bar":
|
||||||
|
return <FaChartBar size={10} />;
|
||||||
|
case "area":
|
||||||
|
return <FaChartArea size={10} />;
|
||||||
|
case "pie":
|
||||||
|
return <FaChartPie size={10} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className={`bg-secondary rounded-lg border border-primary p-2 transition-all ${!widget.visible ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1 px-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-tertiary">{getIcon()}</span>
|
||||||
|
<h3 className="text-[11px] font-medium text-primary">
|
||||||
|
{widget.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={onToggleVisibility}
|
||||||
|
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
||||||
|
title={widget.visible ? "Скрыть" : "Показать"}
|
||||||
|
>
|
||||||
|
{widget.visible ? (
|
||||||
|
<FaEye size={9} className="text-tertiary" />
|
||||||
|
) : (
|
||||||
|
<FaEyeSlash size={9} className="text-tertiary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
<FaCog size={9} className="text-tertiary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-40">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// modules/dashboard/components/WidgetSettings.tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartType, ChartWidget } from "../types";
|
||||||
|
|
||||||
|
interface WidgetSettingsProps {
|
||||||
|
widget: ChartWidget;
|
||||||
|
onUpdate: (widget: ChartWidget) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WidgetSettings: React.FC<WidgetSettingsProps> = ({
|
||||||
|
widget,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [type, setType] = useState<ChartType>(widget.type);
|
||||||
|
const [title, setTitle] = useState(widget.title);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate({ ...widget, type, title });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-primary mb-3">
|
||||||
|
Настройки графика
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">Тип</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
||||||
|
type === t
|
||||||
|
? "bg-accent-primary text-white"
|
||||||
|
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "line" && "📈"}
|
||||||
|
{t === "bar" && "📊"}
|
||||||
|
{t === "area" && "📉"}
|
||||||
|
{t === "pie" && "🥧"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="px-2 py-1 bg-red-500/10 text-red-500 rounded text-[10px] hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartType, MetricData } from "../types";
|
||||||
|
|
||||||
|
interface DashboardChartProps {
|
||||||
|
title: string;
|
||||||
|
type: ChartType;
|
||||||
|
data: MetricData[];
|
||||||
|
dataKeys: string[];
|
||||||
|
colors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
|
||||||
|
|
||||||
|
export const DashboardChart: React.FC<DashboardChartProps> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
dataKeys,
|
||||||
|
colors = COLORS,
|
||||||
|
}) => {
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Нет данных
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
data,
|
||||||
|
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const axisStyle = {
|
||||||
|
stroke: "var(--text-secondary)",
|
||||||
|
tick: { fontSize: 10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "11px",
|
||||||
|
},
|
||||||
|
labelStyle: { color: "var(--text-primary)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "pie") {
|
||||||
|
// Если данные уже в формате { name, value } — используем напрямую
|
||||||
|
const isPieFormat =
|
||||||
|
data.length > 0 && "name" in data[0] && "value" in data[0];
|
||||||
|
|
||||||
|
const pieData = isPieFormat
|
||||||
|
? data
|
||||||
|
: data.map((point, i) => ({
|
||||||
|
name: dataKeys[i % dataKeys.length],
|
||||||
|
value: point[dataKeys[i % dataKeys.length]] || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={60}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip {...tooltipStyle} />
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "11px" }}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartComponent =
|
||||||
|
type === "line" ? LineChart : type === "area" ? AreaChart : BarChart;
|
||||||
|
const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartComponent {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
{...axisStyle}
|
||||||
|
interval={Math.floor(data.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis {...axisStyle} width={35} />
|
||||||
|
<Tooltip {...tooltipStyle} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
{dataKeys.map((key, i) => (
|
||||||
|
<DataComponent
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={colors[i % colors.length]}
|
||||||
|
fill={colors[i % colors.length]}
|
||||||
|
fillOpacity={type === "area" ? 0.2 : undefined}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name={key}
|
||||||
|
radius={type === "bar" ? [2, 2, 0, 0] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChartComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div style={{ height: 180 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user