88 Commits

Author SHA1 Message Date
d3m0k1d 5214f183ff fix secrets
release-agent / release (push) Failing after 3m12s
2026-04-05 11:42:29 +03:00
d3m0k1d 56db916f29 .env 2026-04-05 11:34:25 +03:00
d3m0k1d 0a2d41d04e merge frontend: keep backend README
ci-agent / build (push) Failing after 5m24s
ci-agent / build (pull_request) Failing after 5m10s
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-05 11:02:31 +03:00
d3m0k1d a70791898c fix readme
ci-agent / build (push) Failing after 5m24s
2026-04-05 10:57:14 +03:00
nikita 26323dfd15 super fix
ci-front / build (push) Successful in 2m28s
2026-04-05 10:41:12 +03:00
nikita 7d2f3d0f3a fix 2
ci-front / build (push) Successful in 2m21s
2026-04-05 10:34:33 +03:00
nikita 255fe2eaf3 fix
ci-front / build (push) Successful in 3m18s
2026-04-05 10:14:53 +03:00
d3m0k1d 6d6dd91241 docs
ci-agent / build (push) Failing after 14m57s
2026-04-05 09:32:12 +03:00
nikita 915aa7018a feat: graph 2
ci-front / build (push) Successful in 3m39s
2026-04-05 09:19:39 +03:00
d3m0k1d eb8aef11a4 fix unknow agent
ci-agent / build (push) Failing after 7m40s
2026-04-05 08:46:29 +03:00
d3m0k1d 4a00c95d25 feat: add config for graph test
ci-agent / build (push) Has been cancelled
2026-04-05 08:40:34 +03:00
d3m0k1d e9fdaf8711 upd graph
ci-agent / build (push) Has started running
2026-04-05 08:34:04 +03:00
d3m0k1d 413e31c711 feat: update test config
ci-agent / build (push) Failing after 5m6s
2026-04-05 08:27:35 +03:00
nikita c175461634 fix: adaptive #4
ci-front / build (push) Successful in 2m29s
2026-04-05 08:27:14 +03:00
d3m0k1d f26fa3da69 feat: add logif for checl alive func
ci-agent / build (push) Has been cancelled
2026-04-05 08:25:08 +03:00
nikita 5b90447984 fix: adaptive #3 2026-04-05 08:23:50 +03:00
zero@thinky 247505a310 feat(backend): add root cause calculation
ci-agent / build (push) Has started running
2026-04-05 08:22:41 +03:00
zero@thinky ad9d567d2c feat(backend): add root cause calculation
ci-agent / build (push) Has been cancelled
2026-04-05 08:19:34 +03:00
zero@thinky c6c46aee68 feat(agent): unify service statuses across monitor impls 2026-04-05 08:07:32 +03:00
nikita 9f6defd25c fix: adaptive #2
ci-front / build (push) Successful in 2m28s
2026-04-05 08:04:42 +03:00
zero@thinky 2714bd1178 docs
ci-agent / build (push) Failing after 4m41s
2026-04-05 07:50:57 +03:00
zero@thinky 7aa25b02c5 feat(backend): add service graph yaml
ci-agent / build (push) Has been cancelled
2026-04-05 07:48:55 +03:00
nikita 5f6c4303db fix: adaptive #1
ci-front / build (push) Successful in 2m39s
2026-04-05 07:41:34 +03:00
nikita 17d4770de6 feat: dashboard
ci-front / build (push) Successful in 2m18s
2026-04-05 07:17:33 +03:00
nikita 337e5891f3 feat: update tamplates
ci-front / build (push) Successful in 2m18s
2026-04-05 07:07:14 +03:00
nikita 2bc3da21fd feat: launch scripts
ci-front / build (push) Successful in 2m19s
2026-04-05 06:54:33 +03:00
d3m0k1d d79e9dd829 fix: interpretaor_id on scripts
ci-agent / build (push) Failing after 5m27s
2026-04-05 06:25:00 +03:00
d3m0k1d a4b7024bb8 chore: add default scripts
ci-agent / build (push) Failing after 5m20s
2026-04-05 06:13:25 +03:00
d3m0k1d 87f3836657 docs:upd
ci-agent / build (push) Failing after 5m16s
2026-04-05 05:43:55 +03:00
d3m0k1d c2e8037560 chore: add system metrics 2026-04-05 05:38:03 +03:00
zero@thinky 54e8102a51 fix(backend): interpreters repo not configured
ci-agent / build (push) Failing after 4m45s
2026-04-05 05:35:43 +03:00
zero@thinky 5ccb752836 refactor!(backend): remove check_cmd nonsense 2026-04-05 05:09:14 +03:00
zero@thinky 2616669ab1 fix(backend): job model wasn't reflecting the nullable fields
ci-agent / build (push) Failing after 5m6s
2026-04-05 05:05:34 +03:00
zero@thinky 71a8fa154b feat!(backend/jobs): don't require agent_id on waitjob
ci-agent / build (push) Failing after 5m30s
2026-04-05 04:57:43 +03:00
nikita d6512d6c97 feat: update button run scripts
ci-front / build (push) Successful in 2m35s
2026-04-05 04:57:16 +03:00
zero@thinky b1e6775f1b feat(backend/jobs): add agent_id parameter
ci-agent / build (push) Failing after 5m40s
2026-04-05 04:46:50 +03:00
zero@thinky 8226429b5b feat!(backend): unify script run and ad-hoc job run 2026-04-05 04:46:50 +03:00
zero@thinky add1242b97 feat(agent): add service monitors configuration 2026-04-05 04:11:20 +03:00
zero@thinky 5475912365 fixup! feat(agent): add service monitor interface and docker implementation 2026-04-05 04:11:20 +03:00
zero@thinky b86c36d996 feat(agent): add k8s service monitor implementation 2026-04-05 04:11:20 +03:00
zero@thinky 6eacc79445 feat(agent): add service monitor interface and docker implementation 2026-04-05 04:11:20 +03:00
nikita f14490c076 feat: rename
ci-front / build (push) Successful in 2m24s
2026-04-05 03:59:08 +03:00
d3m0k1d 1f6908900b chore: add handlers for rename dir ans scripts
ci-agent / build (push) Failing after 2m50s
2026-04-05 03:29:36 +03:00
nikita 178c3b53f7 feat: remove folders & create folder 2026-04-05 03:28:31 +03:00
d3m0k1d 534d6aa738 fix: create folder
ci-agent / build (push) Failing after 2m40s
2026-04-05 03:19:32 +03:00
d3m0k1d aae27fa5e0 chore: add logic for scripts
ci-agent / build (push) Failing after 2m58s
2026-04-05 02:18:34 +03:00
nikita 5073cfd357 feat: create files
ci-front / build (push) Successful in 2m31s
2026-04-05 02:09:23 +03:00
d3m0k1d 3e5e4815d9 chore: add k8s and docker as service to agent and update logic for ansible deploy
ci-agent / build (push) Failing after 2m35s
2026-04-05 01:43:38 +03:00
nikita f71a3b1a03 feat: save files
ci-front / build (push) Successful in 2m15s
2026-04-05 01:37:10 +03:00
nikita e024f91111 fix: render files
ci-front / build (push) Successful in 2m5s
2026-04-05 01:07:03 +03:00
nikita 8f5558fdb7 Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 1m59s
2026-04-05 00:56:55 +03:00
nikita 07066ec8c0 feat: request for tree 2026-04-05 00:56:48 +03:00
NikitaTorbenko 31eecf4ba5 Merge branch 'frontend' of https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN into frontend
ci-front / build (push) Has been cancelled
2026-04-05 00:55:21 +03:00
NikitaTorbenko cf6065b55a feat: complete logs 2026-04-05 00:55:05 +03:00
zero@thinky 428140ff15 feat(backend): add job metrics
ci-agent / build (push) Failing after 3m1s
2026-04-05 00:44:57 +03:00
zero@thinky 7be99f8e91 feat: big ahh commit
- agent+proto+backend: transfer service status
- agent: fix returning empty message on nonzero exit status
- backend: refactor collector+commander and handlers dependent on them: implement agent accounting via grpc stats handler
2026-04-05 00:44:56 +03:00
NikitaTorbenko 43ea41f633 fix: logs filter 2026-04-05 00:13:09 +03:00
nikita 6b82c99d50 fix: sidebar & admin.api
ci-front / build (push) Successful in 2m1s
2026-04-04 22:58:09 +03:00
nikita c73035019f fix: hiden button 'Добавить связь'
ci-front / build (push) Successful in 2m14s
2026-04-04 21:44:39 +03:00
nikita e3fae7a02c Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 1m54s
2026-04-04 21:42:45 +03:00
nikita d46d0f8253 feat: adminka2 2026-04-04 21:41:48 +03:00
NikitaTorbenko bcca8fa298 fix: 401
ci-front / build (push) Successful in 1m57s
2026-04-04 21:38:25 +03:00
NikitaTorbenko 400ceab47c fix: for crush
ci-front / build (push) Has been cancelled
2026-04-04 20:05:25 +03:00
NikitaTorbenko c6a9907822 feat
ci-front / build (push) Successful in 2m26s
2026-04-04 19:49:37 +03:00
NikitaTorbenko 69ff617c30 fix: auth store
ci-front / build (push) Successful in 2m27s
2026-04-04 18:20:52 +03:00
nikita 3430070df8 feat: adminka
ci-front / build (push) Successful in 2m11s
2026-04-04 18:13:54 +03:00
nikita 78f35f6811 feat: dashboard-page
ci-front / build (push) Successful in 2m7s
2026-04-04 16:53:12 +03:00
nikita 55cb214458 feat: themes
ci-front / build (push) Successful in 2m17s
2026-04-04 13:38:32 +03:00
nikita 8175d7b3a5 fix: save code in ide
ci-front / build (push) Successful in 2m11s
2026-04-04 12:57:03 +03:00
nikita 822f953698 fix: forceGraph
ci-front / build (push) Successful in 2m18s
2026-04-04 12:51:35 +03:00
nikita e7f1ea2386 fix: graphs
ci-front / build (push) Successful in 2m5s
2026-04-04 12:38:21 +03:00
nikita aac3fa3758 feat: graph-page
ci-front / build (push) Successful in 1m58s
2026-04-04 12:14:17 +03:00
nikita 26ca7c0d51 redezign: list agents & services; feat: button remove agents
ci-front / build (push) Successful in 2m5s
2026-04-04 11:08:45 +03:00
NikitaTorbenko dd921e5892 fix: logs filter adaptive
ci-front / build (push) Successful in 2m30s
2026-04-04 07:11:41 +03:00
NikitaTorbenko eedc9c9b62 fix: menu adaptive
ci-front / build (push) Successful in 2m18s
2026-04-04 07:06:27 +03:00
nikita 4f69e002c6 Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 2m27s
2026-04-04 06:19:17 +03:00
nikita 5209e8b2e9 fix: conflicts 2026-04-04 06:17:09 +03:00
NikitaTorbenko 95a6902dae feat: create logs
ci-front / build (push) Successful in 2m24s
2026-04-04 06:13:12 +03:00
nikita adbb0ee368 feat: page tempaltes 2026-04-04 06:05:51 +03:00
NikitaTorbenko 96f82b4162 feat: create register
ci-front / build (push) Successful in 2m1s
2026-04-04 05:57:34 +03:00
NikitaTorbenko ed439656f8 feat: add registration token
ci-front / build (push) Successful in 2m22s
2026-04-04 05:52:43 +03:00
NikitaTorbenko d62205b329 fix: add agent
ci-front / build (push) Successful in 2m28s
2026-04-04 05:46:01 +03:00
NikitaTorbenko 11cef95929 feat: add admin + deploy
ci-front / build (push) Successful in 2m28s
2026-04-04 05:39:00 +03:00
nikita 43e16b1360 fix: autocloseder for input search & button back
ci-front / build (push) Successful in 2m23s
2026-04-04 05:13:27 +03:00
nikita f537f1eab9 feat: IDE
ci-front / build (push) Successful in 2m19s
2026-04-04 04:59:42 +03:00
nikita 9d1096a9b4 fix: 2 2026-04-04 03:37:27 +03:00
NikitaTorbenko 57b43da2e3 feat: add layout
ci-front / build (push) Successful in 2m9s
2026-04-04 03:07:45 +03:00
NikitaTorbenko 691e1fced5 feat: add swagger docs
ci-front / build (push) Successful in 2m26s
2026-04-04 02:44:36 +03:00
137 changed files with 27221 additions and 1181 deletions
+32
View File
@@ -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 }}
+358 -1
View File
@@ -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
```
+3 -20
View File
@@ -12,7 +12,7 @@ gitea_urls:
builds: builds:
- id: banforge - id: banforge
main: ./cmd/banforge/main.go main: ./main.go
binary: banforge binary: banforge
ignore: ignore:
- goos: windows - goos: windows
@@ -24,12 +24,9 @@ builds:
- amd64 - amd64
- arm64 - arm64
ldflags: ldflags:
- "-s -w" - "-s -w"
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=0
archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
nfpms: nfpms:
- id: banforge - id: banforge
@@ -40,23 +37,9 @@ nfpms:
maintainer: d3m0k1d <contact@d3m0k1d.ru> maintainer: d3m0k1d <contact@d3m0k1d.ru>
license: GPLv3.0 license: GPLv3.0
formats: formats:
- apk
- deb - deb
- rpm - rpm
- archlinux
bindir: /usr/bin bindir: /usr/bin
scripts:
postinstall: build/postinstall.sh
postremove: build/postremove.sh
contents:
- src: docs/man/banforge.1
dst: /usr/share/man/man1/banforge.1
file_info:
mode: 0644
- src: docs/man/banforge.5
dst: /usr/share/man/man5/banforge.5
file_info:
mode: 0644
release: release:
gitea: gitea:
owner: d3m0k1d owner: d3m0k1d
@@ -74,7 +57,7 @@ checksum:
algorithm: sha256 algorithm: sha256
sboms: sboms:
- artifacts: archive - artifacts: any
documents: documents:
- "{{ .ArtifactName }}.spdx.json" - "{{ .ArtifactName }}.spdx.json"
cmd: syft cmd: syft
+54 -1
View File
@@ -3,15 +3,67 @@ 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/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 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 google.golang.org/grpc v1.80.0
gopkg.in/yaml.v3 v3.0.1 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 modernc.org/sqlite v1.34.5
) )
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.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 ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -28,6 +80,7 @@ require (
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/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // 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/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect modernc.org/memory v1.8.0 // indirect
+137 -3
View File
@@ -1,33 +1,127 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 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 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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.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 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= 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=
@@ -38,17 +132,27 @@ 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 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 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.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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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=
@@ -59,14 +163,34 @@ 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 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 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 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 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
@@ -91,3 +215,13 @@ modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 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=
+8 -2
View File
@@ -47,8 +47,14 @@ func (*CommandExecutor) Execute(command *proto.Command) (fc *proto.FinishedComma
_, 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
+9 -3
View File
@@ -8,9 +8,10 @@ import (
) )
type ServiceConfig struct { type ServiceConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Type string `yaml:"type"` Type string `yaml:"type"`
Path *string `yaml:"path"` Path *string `yaml:"path"`
SystemdUnit *string `yaml:"systemd_unit"` // Optional: systemd unit name for health check
} }
type AgentConfig struct { type AgentConfig struct {
@@ -20,6 +21,11 @@ type AgentConfig struct {
Label string `yaml:"label"` Label string `yaml:"label"`
CertDir string `yaml:"cert_dir"` CertDir string `yaml:"cert_dir"`
Services []ServiceConfig `yaml:"services"` 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) { func Load(path string) (*AgentConfig, error) {
+89
View File
@@ -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,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
}
+214
View File
@@ -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
}
+22
View File
@@ -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
}
+58
View File
@@ -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
}
}
+11
View File
@@ -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)
}
+66
View File
@@ -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
}
}
+177
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec"
"strings" "strings"
"time" "time"
@@ -12,16 +13,20 @@ import (
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/client" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/client"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config" "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/logger"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource" "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/file"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald" "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/mtls"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"github.com/samber/lo" "github.com/samber/lo"
"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/metadata" "google.golang.org/grpc/metadata"
) )
@@ -110,6 +115,18 @@ func main() {
return ccli.HandleCommands(ctx, grpcAddr, creds) 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 // Start log collectors
if len(cfg.Services) > 0 { if len(cfg.Services) > 0 {
wg.Go(func() error { wg.Go(func() error {
@@ -139,6 +156,16 @@ func main() {
if err != nil { if err != nil {
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err) 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: default:
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name) return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
} }
@@ -301,3 +328,153 @@ func reconnectStream(
return fmt.Errorf("failed to reconnect after 5 attempts for service %s", service) 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:
}
}
}
+60 -10
View File
@@ -82,23 +82,35 @@ func main() {
}() }()
} }
// Initialize Collector gRPC service // Initialize Collector (log streaming) with its own ConnTracker
coll := collector.New(logRepo) collTracker := collector.NewConnTracker()
coll := collector.New(logRepo, collTracker)
cmdr := commander.New(jobRepo) // Initialize ConnTracker for Commander agent lifecycle
cmdTracker := commander.NewConnTracker()
cmdr := commander.New(jobRepo, cmdTracker)
// Initialize script interpreter repository and service // Initialize script interpreter repository and service
scriptRepo := repository.NewScriptInterpreterRepo(db) scriptRepo := repository.NewScriptInterpreterRepo(db)
if err := scriptRepo.Init(context.Background()); err != nil { if err := scriptRepo.Init(context.Background()); err != nil {
log.Printf("Warning: failed to initialize script interpreters table: %v", err) log.Fatalf("Warning: failed to initialize script interpreters table: %v\n", err)
} }
scriptSvc := service.NewScriptServiceWithInterpreters(h.Repo, scriptRepo) scriptSvc := service.NewScriptServiceWithInterpreters(h.Repo, scriptRepo)
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdr) scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdTracker,
jobsHandlers := handlers.NewJobsHandlers(cmdr, scriptSvc) os.Getenv("WHEREAMI"))
jobsHandlers := handlers.NewJobsHandlers(cmdTracker, scriptSvc,
os.Getenv("WHEREAMI"), /* our address for redirects */
jobRepo,
)
// Initialize script management service and handlers scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptSvc, cmdr,
scriptManageSvc := service.NewScriptService(h.Repo) os.Getenv("WHEREAMI"))
scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptManageSvc, cmdr)
graphPath := os.Getenv("GRAPH_YAML_PATH")
if graphPath == "" {
graphPath = "/etc/hellreign/services.yaml"
}
graphHandlers := handlers.NewGraphHandlers(graphPath, coll)
agents := handlers.NewAgentsGroup(h, coll) agents := handlers.NewAgentsGroup(h, coll)
auth := handlers.AuthGroup{Handlers: h} auth := handlers.AuthGroup{Handlers: h}
@@ -194,6 +206,7 @@ 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) // Jobs (requires admin permission)
@@ -201,6 +214,29 @@ func main() {
jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin()) jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
{ {
jobsGroup.POST("", jobsHandlers.AddJob) 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 // Agent registration
@@ -251,6 +287,16 @@ func main() {
scriptsGroup.PUT("/:id", scriptManageHandlers.UpdateScript) scriptsGroup.PUT("/:id", scriptManageHandlers.UpdateScript)
scriptsGroup.DELETE("/:id", scriptManageHandlers.DeleteScript) scriptsGroup.DELETE("/:id", scriptManageHandlers.DeleteScript)
scriptsGroup.POST("/:id/run", scriptManageHandlers.RunScriptByID) 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)
} }
} }
@@ -290,7 +336,11 @@ func main() {
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
} }
grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) grpcServer := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsConfig)),
grpc.StatsHandler(collTracker),
grpc.StatsHandler(cmdTracker),
)
proto.RegisterCommanderServer(grpcServer, cmdr) proto.RegisterCommanderServer(grpcServer, cmdr)
proto.RegisterCollectorServer(grpcServer, coll) proto.RegisterCollectorServer(grpcServer, coll)
+680 -44
View File
@@ -177,6 +177,37 @@ const docTemplate = `{
} }
} }
}, },
"/agents/system-metrics": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns CPU, RAM, disk, and network usage metrics for all connected agents",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"agents"
],
"summary": "Get agent system metrics",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_handlers.AgentSystemMetricsOut"
}
}
}
}
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"description": "Authenticate with login and password, returns a token and permissions", "description": "Authenticate with login and password, returns a token and permissions",
@@ -963,14 +994,198 @@ const docTemplate = `{
} }
} }
}, },
"/jobs": { "/graph": {
"post": { "get": {
"security": [ "security": [
{ {
"Bearer": [] "Bearer": []
} }
], ],
"description": "Sends a command to the specified agent, waits for execution, and returns the result", "description": "Returns the service dependency graph as JSON",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Get dependency graph",
"responses": {
"200": {
"description": "Dependency graph",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"description": "Replaces the service dependency graph YAML and reloads it",
"consumes": [
"text/plain"
],
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Update dependency graph YAML",
"parameters": [
{
"description": "New YAML content",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/graph/cycle": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns whether the dependency graph contains cycles",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Check for cycles",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
}
}
},
"/graph/failure": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Analyzes dependencies and service statuses to find the root cause of a failure",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Find failure root cause",
"parameters": [
{
"type": "string",
"description": "Node ID (agent label)",
"name": "node_id",
"in": "query"
},
{
"type": "string",
"description": "Service name",
"name": "service",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handlers.FailureRootCauseOut"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/graph/order": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns the topologically sorted service startup order",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Get startup order",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/jobs": {
"post": {
"description": "Sends a command to the specified agent and returns a URL to wait for the result",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -980,7 +1195,7 @@ const docTemplate = `{
"tags": [ "tags": [
"jobs" "jobs"
], ],
"summary": "Create and run a job on an agent", "summary": "Submit a job to an agent",
"parameters": [ "parameters": [
{ {
"description": "Job request", "description": "Job request",
@@ -1002,6 +1217,105 @@ const docTemplate = `{
} }
} }
}, },
"/jobs/metrics": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns total, successful, failed, and pending job counts over the given period",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Get job metrics",
"parameters": [
{
"type": "string",
"default": "24h",
"description": "Time period (e.g. 1h, 24h, 7d)",
"name": "period",
"in": "query"
},
{
"type": "string",
"description": "Filter by agent ID",
"name": "agent_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handlers.JobMetricsOut"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/jobs/{id}/wait": {
"post": {
"description": "Long-polls for a job result. Returns immediately if the job is already finished.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Wait for job result",
"parameters": [
{
"type": "integer",
"description": "Job ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handlers.JobResult"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/logs": { "/logs": {
"get": { "get": {
"security": [ "security": [
@@ -1514,7 +1828,7 @@ const docTemplate = `{
"Bearer": [] "Bearer": []
} }
], ],
"description": "Loads a script from storage, resolves interpreter command, and executes on the specified agent", "description": "Loads a script from storage, resolves interpreter command, and submits it to the agent",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1534,7 +1848,7 @@ const docTemplate = `{
"required": true "required": true
}, },
{ {
"description": "Agent token and optional stdin", "description": "Agent ID and optional stdin",
"name": "body", "name": "body",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -1547,7 +1861,7 @@ const docTemplate = `{
"201": { "201": {
"description": "Created", "description": "Created",
"schema": { "schema": {
"$ref": "#/definitions/internal_handlers.RunScriptOut" "$ref": "#/definitions/internal_handlers.AddJobOut"
} }
}, },
"400": { "400": {
@@ -1580,6 +1894,138 @@ const docTemplate = `{
} }
} }
}, },
"/scripts/by-path": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')",
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Get script by path",
"parameters": [
{
"type": "string",
"description": "Script path",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/scripts/folder": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "Creates a virtual folder by creating a placeholder script with the folder path",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Create folder",
"parameters": [
{
"description": "Folder path",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handlers.CreateFolderRequest"
}
}
],
"responses": {
"201": {
"description": "Folder created",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"description": "Deletes all scripts that start with the given folder path",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Delete folder",
"parameters": [
{
"description": "Folder path",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handlers.DeleteFolderRequest"
}
}
],
"responses": {
"200": {
"description": "Folder deleted with count of deleted scripts",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/scripts/interpreters": { "/scripts/interpreters": {
"get": { "get": {
"security": [ "security": [
@@ -1749,6 +2195,73 @@ const docTemplate = `{
} }
} }
}, },
"/scripts/rename": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "Renames a single script or all scripts under a folder prefix",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Rename script or folder",
"parameters": [
{
"description": "Rename request",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handlers.RenameRequest"
}
}
],
"responses": {
"200": {
"description": "Rename result with count of renamed scripts",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/scripts/run": { "/scripts/run": {
"post": { "post": {
"security": [ "security": [
@@ -1782,7 +2295,7 @@ const docTemplate = `{
"201": { "201": {
"description": "Created", "description": "Created",
"schema": { "schema": {
"$ref": "#/definitions/internal_handlers.RunScriptOut" "$ref": "#/definitions/internal_handlers.AddJobOut"
} }
} }
} }
@@ -2337,16 +2850,7 @@ const docTemplate = `{
"id": { "id": {
"type": "integer" "type": "integer"
}, },
"status": { "wait_url": {
"type": "integer"
},
"stderr": {
"type": "string"
},
"stdin": {
"type": "string"
},
"stdout": {
"type": "string" "type": "string"
} }
} }
@@ -2382,6 +2886,84 @@ const docTemplate = `{
} }
} }
}, },
"internal_handlers.AgentSystemMetricsOut": {
"type": "object",
"properties": {
"connected_at": {
"type": "string",
"example": "2026-04-04 10:30:00"
},
"cpu_percent": {
"type": "number",
"example": 45.2
},
"disk_percent": {
"type": "number",
"example": 78.9
},
"id": {
"type": "string",
"example": "agent-001"
},
"label": {
"type": "string",
"example": "web-server-1"
},
"memory_percent": {
"type": "number",
"example": 62.5
},
"network_rx_bytes": {
"type": "number",
"example": 1048576
},
"network_tx_bytes": {
"type": "number",
"example": 524288
}
}
},
"internal_handlers.CreateFolderRequest": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"example": "deploy/nginx"
}
}
},
"internal_handlers.DeleteFolderRequest": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"example": "deploy/nginx"
}
}
},
"internal_handlers.FailureRootCauseOut": {
"type": "object",
"properties": {
"affected": {
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
},
"dependency_chain": {
"type": "array",
"items": {
"type": "string"
}
},
"root_cause": {
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
}
}
},
"internal_handlers.InsertLogRequest": { "internal_handlers.InsertLogRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2422,6 +3004,52 @@ const docTemplate = `{
} }
} }
}, },
"internal_handlers.JobMetricsOut": {
"type": "object",
"properties": {
"failed": {
"type": "integer"
},
"pending": {
"type": "integer"
},
"period": {
"type": "string"
},
"success": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"internal_handlers.JobResult": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "integer"
},
"status": {
"type": "integer"
},
"stderr": {
"type": "string"
},
"stdin": {
"type": "string"
},
"stdout": {
"type": "string"
}
}
},
"internal_handlers.RegisterRequest": { "internal_handlers.RegisterRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2448,6 +3076,23 @@ const docTemplate = `{
} }
} }
}, },
"internal_handlers.RenameRequest": {
"type": "object",
"required": [
"new_path",
"old_path"
],
"properties": {
"new_path": {
"type": "string",
"example": "deploy/nginx-v2"
},
"old_path": {
"type": "string",
"example": "deploy/nginx"
}
}
},
"internal_handlers.RunScriptIn": { "internal_handlers.RunScriptIn": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2470,32 +3115,6 @@ const docTemplate = `{
} }
} }
}, },
"internal_handlers.RunScriptOut": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "integer"
},
"status": {
"type": "integer"
},
"stderr": {
"type": "string"
},
"stdin": {
"type": "string"
},
"stdout": {
"type": "string"
}
}
},
"internal_handlers.RunStoredScriptIn": { "internal_handlers.RunStoredScriptIn": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2509,6 +3128,23 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
} }
},
"internal_handlers.ServiceStatusOut": {
"type": "object",
"properties": {
"healthy": {
"type": "boolean"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"status": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {
+680 -44
View File
@@ -166,6 +166,37 @@
} }
} }
}, },
"/agents/system-metrics": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns CPU, RAM, disk, and network usage metrics for all connected agents",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"agents"
],
"summary": "Get agent system metrics",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_handlers.AgentSystemMetricsOut"
}
}
}
}
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"description": "Authenticate with login and password, returns a token and permissions", "description": "Authenticate with login and password, returns a token and permissions",
@@ -952,14 +983,198 @@
} }
} }
}, },
"/jobs": { "/graph": {
"post": { "get": {
"security": [ "security": [
{ {
"Bearer": [] "Bearer": []
} }
], ],
"description": "Sends a command to the specified agent, waits for execution, and returns the result", "description": "Returns the service dependency graph as JSON",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Get dependency graph",
"responses": {
"200": {
"description": "Dependency graph",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"description": "Replaces the service dependency graph YAML and reloads it",
"consumes": [
"text/plain"
],
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Update dependency graph YAML",
"parameters": [
{
"description": "New YAML content",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/graph/cycle": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns whether the dependency graph contains cycles",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Check for cycles",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
}
}
},
"/graph/failure": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Analyzes dependencies and service statuses to find the root cause of a failure",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Find failure root cause",
"parameters": [
{
"type": "string",
"description": "Node ID (agent label)",
"name": "node_id",
"in": "query"
},
{
"type": "string",
"description": "Service name",
"name": "service",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handlers.FailureRootCauseOut"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/graph/order": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns the topologically sorted service startup order",
"produces": [
"application/json"
],
"tags": [
"graph"
],
"summary": "Get startup order",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/jobs": {
"post": {
"description": "Sends a command to the specified agent and returns a URL to wait for the result",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -969,7 +1184,7 @@
"tags": [ "tags": [
"jobs" "jobs"
], ],
"summary": "Create and run a job on an agent", "summary": "Submit a job to an agent",
"parameters": [ "parameters": [
{ {
"description": "Job request", "description": "Job request",
@@ -991,6 +1206,105 @@
} }
} }
}, },
"/jobs/metrics": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns total, successful, failed, and pending job counts over the given period",
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Get job metrics",
"parameters": [
{
"type": "string",
"default": "24h",
"description": "Time period (e.g. 1h, 24h, 7d)",
"name": "period",
"in": "query"
},
{
"type": "string",
"description": "Filter by agent ID",
"name": "agent_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handlers.JobMetricsOut"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/jobs/{id}/wait": {
"post": {
"description": "Long-polls for a job result. Returns immediately if the job is already finished.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"jobs"
],
"summary": "Wait for job result",
"parameters": [
{
"type": "integer",
"description": "Job ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_handlers.JobResult"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/logs": { "/logs": {
"get": { "get": {
"security": [ "security": [
@@ -1503,7 +1817,7 @@
"Bearer": [] "Bearer": []
} }
], ],
"description": "Loads a script from storage, resolves interpreter command, and executes on the specified agent", "description": "Loads a script from storage, resolves interpreter command, and submits it to the agent",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1523,7 +1837,7 @@
"required": true "required": true
}, },
{ {
"description": "Agent token and optional stdin", "description": "Agent ID and optional stdin",
"name": "body", "name": "body",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -1536,7 +1850,7 @@
"201": { "201": {
"description": "Created", "description": "Created",
"schema": { "schema": {
"$ref": "#/definitions/internal_handlers.RunScriptOut" "$ref": "#/definitions/internal_handlers.AddJobOut"
} }
}, },
"400": { "400": {
@@ -1569,6 +1883,138 @@
} }
} }
}, },
"/scripts/by-path": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')",
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Get script by path",
"parameters": [
{
"type": "string",
"description": "Script path",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/scripts/folder": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "Creates a virtual folder by creating a placeholder script with the folder path",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Create folder",
"parameters": [
{
"description": "Folder path",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handlers.CreateFolderRequest"
}
}
],
"responses": {
"201": {
"description": "Folder created",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"description": "Deletes all scripts that start with the given folder path",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Delete folder",
"parameters": [
{
"description": "Folder path",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handlers.DeleteFolderRequest"
}
}
],
"responses": {
"200": {
"description": "Folder deleted with count of deleted scripts",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/scripts/interpreters": { "/scripts/interpreters": {
"get": { "get": {
"security": [ "security": [
@@ -1738,6 +2184,73 @@
} }
} }
}, },
"/scripts/rename": {
"post": {
"security": [
{
"Bearer": []
}
],
"description": "Renames a single script or all scripts under a folder prefix",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"scripts"
],
"summary": "Rename script or folder",
"parameters": [
{
"description": "Rename request",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_handlers.RenameRequest"
}
}
],
"responses": {
"200": {
"description": "Rename result with count of renamed scripts",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "Conflict",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/scripts/run": { "/scripts/run": {
"post": { "post": {
"security": [ "security": [
@@ -1771,7 +2284,7 @@
"201": { "201": {
"description": "Created", "description": "Created",
"schema": { "schema": {
"$ref": "#/definitions/internal_handlers.RunScriptOut" "$ref": "#/definitions/internal_handlers.AddJobOut"
} }
} }
} }
@@ -2326,16 +2839,7 @@
"id": { "id": {
"type": "integer" "type": "integer"
}, },
"status": { "wait_url": {
"type": "integer"
},
"stderr": {
"type": "string"
},
"stdin": {
"type": "string"
},
"stdout": {
"type": "string" "type": "string"
} }
} }
@@ -2371,6 +2875,84 @@
} }
} }
}, },
"internal_handlers.AgentSystemMetricsOut": {
"type": "object",
"properties": {
"connected_at": {
"type": "string",
"example": "2026-04-04 10:30:00"
},
"cpu_percent": {
"type": "number",
"example": 45.2
},
"disk_percent": {
"type": "number",
"example": 78.9
},
"id": {
"type": "string",
"example": "agent-001"
},
"label": {
"type": "string",
"example": "web-server-1"
},
"memory_percent": {
"type": "number",
"example": 62.5
},
"network_rx_bytes": {
"type": "number",
"example": 1048576
},
"network_tx_bytes": {
"type": "number",
"example": 524288
}
}
},
"internal_handlers.CreateFolderRequest": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"example": "deploy/nginx"
}
}
},
"internal_handlers.DeleteFolderRequest": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"example": "deploy/nginx"
}
}
},
"internal_handlers.FailureRootCauseOut": {
"type": "object",
"properties": {
"affected": {
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
},
"dependency_chain": {
"type": "array",
"items": {
"type": "string"
}
},
"root_cause": {
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
}
}
},
"internal_handlers.InsertLogRequest": { "internal_handlers.InsertLogRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2411,6 +2993,52 @@
} }
} }
}, },
"internal_handlers.JobMetricsOut": {
"type": "object",
"properties": {
"failed": {
"type": "integer"
},
"pending": {
"type": "integer"
},
"period": {
"type": "string"
},
"success": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"internal_handlers.JobResult": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "integer"
},
"status": {
"type": "integer"
},
"stderr": {
"type": "string"
},
"stdin": {
"type": "string"
},
"stdout": {
"type": "string"
}
}
},
"internal_handlers.RegisterRequest": { "internal_handlers.RegisterRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2437,6 +3065,23 @@
} }
} }
}, },
"internal_handlers.RenameRequest": {
"type": "object",
"required": [
"new_path",
"old_path"
],
"properties": {
"new_path": {
"type": "string",
"example": "deploy/nginx-v2"
},
"old_path": {
"type": "string",
"example": "deploy/nginx"
}
}
},
"internal_handlers.RunScriptIn": { "internal_handlers.RunScriptIn": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2459,32 +3104,6 @@
} }
} }
}, },
"internal_handlers.RunScriptOut": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "integer"
},
"status": {
"type": "integer"
},
"stderr": {
"type": "string"
},
"stdin": {
"type": "string"
},
"stdout": {
"type": "string"
}
}
},
"internal_handlers.RunStoredScriptIn": { "internal_handlers.RunStoredScriptIn": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -2498,6 +3117,23 @@
"type": "string" "type": "string"
} }
} }
},
"internal_handlers.ServiceStatusOut": {
"type": "object",
"properties": {
"healthy": {
"type": "boolean"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"status": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {
+445 -31
View File
@@ -348,13 +348,7 @@ definitions:
type: array type: array
id: id:
type: integer type: integer
status: wait_url:
type: integer
stderr:
type: string
stdin:
type: string
stdout:
type: string type: string
type: object type: object
internal_handlers.AgentInfo: internal_handlers.AgentInfo:
@@ -380,6 +374,60 @@ definitions:
example: agent-001 example: agent-001
type: string type: string
type: object type: object
internal_handlers.AgentSystemMetricsOut:
properties:
connected_at:
example: "2026-04-04 10:30:00"
type: string
cpu_percent:
example: 45.2
type: number
disk_percent:
example: 78.9
type: number
id:
example: agent-001
type: string
label:
example: web-server-1
type: string
memory_percent:
example: 62.5
type: number
network_rx_bytes:
example: 1048576
type: number
network_tx_bytes:
example: 524288
type: number
type: object
internal_handlers.CreateFolderRequest:
properties:
path:
example: deploy/nginx
type: string
required:
- path
type: object
internal_handlers.DeleteFolderRequest:
properties:
path:
example: deploy/nginx
type: string
required:
- path
type: object
internal_handlers.FailureRootCauseOut:
properties:
affected:
$ref: '#/definitions/internal_handlers.ServiceStatusOut'
dependency_chain:
items:
type: string
type: array
root_cause:
$ref: '#/definitions/internal_handlers.ServiceStatusOut'
type: object
internal_handlers.InsertLogRequest: internal_handlers.InsertLogRequest:
properties: properties:
agent: agent:
@@ -407,6 +455,36 @@ definitions:
required: required:
- logs - logs
type: object type: object
internal_handlers.JobMetricsOut:
properties:
failed:
type: integer
pending:
type: integer
period:
type: string
success:
type: integer
total:
type: integer
type: object
internal_handlers.JobResult:
properties:
command:
items:
type: string
type: array
id:
type: integer
status:
type: integer
stderr:
type: string
stdin:
type: string
stdout:
type: string
type: object
internal_handlers.RegisterRequest: internal_handlers.RegisterRequest:
properties: properties:
csr: csr:
@@ -424,6 +502,18 @@ definitions:
client_cert: client_cert:
type: string type: string
type: object type: object
internal_handlers.RenameRequest:
properties:
new_path:
example: deploy/nginx-v2
type: string
old_path:
example: deploy/nginx
type: string
required:
- new_path
- old_path
type: object
internal_handlers.RunScriptIn: internal_handlers.RunScriptIn:
properties: properties:
agent_id: agent_id:
@@ -439,23 +529,6 @@ definitions:
- interpreter_id - interpreter_id
- script_text - script_text
type: object type: object
internal_handlers.RunScriptOut:
properties:
command:
items:
type: string
type: array
id:
type: integer
status:
type: integer
stderr:
type: string
stdin:
type: string
stdout:
type: string
type: object
internal_handlers.RunStoredScriptIn: internal_handlers.RunStoredScriptIn:
properties: properties:
stdin: stdin:
@@ -465,6 +538,17 @@ definitions:
required: required:
- token - token
type: object type: object
internal_handlers.ServiceStatusOut:
properties:
healthy:
type: boolean
name:
type: string
node_id:
type: string
status:
type: string
type: object
info: info:
contact: {} contact: {}
paths: paths:
@@ -571,6 +655,26 @@ paths:
summary: Create registration token summary: Create registration token
tags: tags:
- agents - agents
/agents/system-metrics:
get:
consumes:
- application/json
description: Returns CPU, RAM, disk, and network usage metrics for all connected
agents
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/internal_handlers.AgentSystemMetricsOut'
type: array
security:
- Bearer: []
summary: Get agent system metrics
tags:
- agents
/auth/login: /auth/login:
post: post:
consumes: consumes:
@@ -1075,12 +1179,131 @@ paths:
summary: Validate token summary: Validate token
tags: tags:
- auth - auth
/graph:
get:
description: Returns the service dependency graph as JSON
produces:
- application/json
responses:
"200":
description: Dependency graph
schema:
additionalProperties: true
type: object
security:
- Bearer: []
summary: Get dependency graph
tags:
- graph
put:
consumes:
- text/plain
description: Replaces the service dependency graph YAML and reloads it
parameters:
- description: New YAML content
in: body
name: body
required: true
schema:
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
security:
- Bearer: []
summary: Update dependency graph YAML
tags:
- graph
/graph/cycle:
get:
description: Returns whether the dependency graph contains cycles
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: boolean
type: object
security:
- Bearer: []
summary: Check for cycles
tags:
- graph
/graph/failure:
get:
description: Analyzes dependencies and service statuses to find the root cause
of a failure
parameters:
- description: Node ID (agent label)
in: query
name: node_id
type: string
- description: Service name
in: query
name: service
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_handlers.FailureRootCauseOut'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
security:
- Bearer: []
summary: Find failure root cause
tags:
- graph
/graph/order:
get:
description: Returns the topologically sorted service startup order
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
type: string
type: array
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
security:
- Bearer: []
summary: Get startup order
tags:
- graph
/jobs: /jobs:
post: post:
consumes: consumes:
- application/json - application/json
description: Sends a command to the specified agent, waits for execution, and description: Sends a command to the specified agent and returns a URL to wait
returns the result for the result
parameters: parameters:
- description: Job request - description: Job request
in: body in: body
@@ -1095,9 +1318,73 @@ paths:
description: Created description: Created
schema: schema:
$ref: '#/definitions/internal_handlers.AddJobOut' $ref: '#/definitions/internal_handlers.AddJobOut'
summary: Submit a job to an agent
tags:
- jobs
/jobs/{id}/wait:
post:
consumes:
- application/json
description: Long-polls for a job result. Returns immediately if the job is
already finished.
parameters:
- description: Job ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_handlers.JobResult'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
summary: Wait for job result
tags:
- jobs
/jobs/metrics:
get:
description: Returns total, successful, failed, and pending job counts over
the given period
parameters:
- default: 24h
description: Time period (e.g. 1h, 24h, 7d)
in: query
name: period
type: string
- description: Filter by agent ID
in: query
name: agent_id
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_handlers.JobMetricsOut'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
security: security:
- Bearer: [] - Bearer: []
summary: Create and run a job on an agent summary: Get job metrics
tags: tags:
- jobs - jobs
/logs: /logs:
@@ -1425,14 +1712,14 @@ paths:
consumes: consumes:
- application/json - application/json
description: Loads a script from storage, resolves interpreter command, and description: Loads a script from storage, resolves interpreter command, and
executes on the specified agent submits it to the agent
parameters: parameters:
- description: Script ID - description: Script ID
in: path in: path
name: id name: id
required: true required: true
type: integer type: integer
- description: Agent token and optional stdin - description: Agent ID and optional stdin
in: body in: body
name: body name: body
required: true required: true
@@ -1444,7 +1731,7 @@ paths:
"201": "201":
description: Created description: Created
schema: schema:
$ref: '#/definitions/internal_handlers.RunScriptOut' $ref: '#/definitions/internal_handlers.AddJobOut'
"400": "400":
description: Bad Request description: Bad Request
schema: schema:
@@ -1468,6 +1755,90 @@ paths:
summary: Run script by ID summary: Run script by ID
tags: tags:
- scripts - scripts
/scripts/by-path:
get:
description: Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')
parameters:
- description: Script path
in: query
name: path
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
security:
- Bearer: []
summary: Get script by path
tags:
- scripts
/scripts/folder:
delete:
consumes:
- application/json
description: Deletes all scripts that start with the given folder path
parameters:
- description: Folder path
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_handlers.DeleteFolderRequest'
produces:
- application/json
responses:
"200":
description: Folder deleted with count of deleted scripts
schema:
additionalProperties: true
type: object
security:
- Bearer: []
summary: Delete folder
tags:
- scripts
post:
consumes:
- application/json
description: Creates a virtual folder by creating a placeholder script with
the folder path
parameters:
- description: Folder path
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_handlers.CreateFolderRequest'
produces:
- application/json
responses:
"201":
description: Folder created
schema:
additionalProperties:
type: string
type: object
security:
- Bearer: []
summary: Create folder
tags:
- scripts
/scripts/interpreters: /scripts/interpreters:
get: get:
description: Returns all script interpreters available in the system description: Returns all script interpreters available in the system
@@ -1573,6 +1944,49 @@ paths:
summary: Update interpreter summary: Update interpreter
tags: tags:
- scripts - scripts
/scripts/rename:
post:
consumes:
- application/json
description: Renames a single script or all scripts under a folder prefix
parameters:
- description: Rename request
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_handlers.RenameRequest'
produces:
- application/json
responses:
"200":
description: Rename result with count of renamed scripts
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"409":
description: Conflict
schema:
additionalProperties:
type: string
type: object
security:
- Bearer: []
summary: Rename script or folder
tags:
- scripts
/scripts/run: /scripts/run:
post: post:
consumes: consumes:
@@ -1591,7 +2005,7 @@ paths:
"201": "201":
description: Created description: Created
schema: schema:
$ref: '#/definitions/internal_handlers.RunScriptOut' $ref: '#/definitions/internal_handlers.AddJobOut'
security: security:
- Bearer: [] - Bearer: []
summary: Run a script on an agent summary: Run a script on an agent
+1 -1
View File
@@ -3,7 +3,7 @@ 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-20260404165608-1d75935a08a5 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/samber/lo v1.53.0
+53 -12
View File
@@ -7,32 +7,39 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"sync" "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 // Executor handles running Ansible playbooks
type Executor struct { type Executor struct {
workDir string workDir string
grpcServerHost string grpcServerHost string
grpcServerPort string grpcServerPort string
backendURL string backendURL string
giteaReleasesURL string
} }
// ExecutorConfig holds configuration for the Executor // ExecutorConfig holds configuration for the Executor
type ExecutorConfig struct { type ExecutorConfig struct {
WorkDir string WorkDir string
GRPCServerHost string GRPCServerHost string
GRPCServerPort string GRPCServerPort string
BackendURL string BackendURL string
GiteaReleasesURL string
} }
// NewExecutor creates a new Ansible executor // NewExecutor creates a new Ansible executor
func NewExecutor(cfg ExecutorConfig) *Executor { func NewExecutor(cfg ExecutorConfig) *Executor {
return &Executor{ return &Executor{
workDir: cfg.WorkDir, workDir: cfg.WorkDir,
grpcServerHost: cfg.GRPCServerHost, grpcServerHost: cfg.GRPCServerHost,
grpcServerPort: cfg.GRPCServerPort, grpcServerPort: cfg.GRPCServerPort,
backendURL: cfg.BackendURL, backendURL: cfg.BackendURL,
giteaReleasesURL: cfg.GiteaReleasesURL,
} }
} }
@@ -55,12 +62,42 @@ func (e *Executor) GRPCURL() string {
return e.grpcServerHost + ":" + e.grpcServerPort 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 // Deploy runs Ansible playbook for the given inventory
func (e *Executor) Deploy( func (e *Executor) Deploy(
ctx context.Context, ctx context.Context,
inventoryPath string, inventoryPath string,
deployType string, deployType string,
) ([]DeployResult, error) { ) ([]DeployResult, error) {
if deployType != "docker" && deployType != "binary" {
return nil, fmt.Errorf("invalid deploy type %q: %w", deployType, ErrUnknownDeployType)
}
playbookName := "binary_deploy.yml" playbookName := "binary_deploy.yml"
if deployType == "docker" { if deployType == "docker" {
playbookName = "docker_deploy.yml" playbookName = "docker_deploy.yml"
@@ -72,6 +109,7 @@ func (e *Executor) Deploy(
"-i", inventoryPath, "-i", inventoryPath,
"-e", fmt.Sprintf("backend_url=%s", e.backendURL), "-e", fmt.Sprintf("backend_url=%s", e.backendURL),
"-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort), "-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort),
"-e", fmt.Sprintf("gitea_releases_url=%s", e.giteaReleasesURL),
playbookPath, playbookPath,
) )
@@ -100,6 +138,7 @@ func (e *Executor) DeployParallel(
deployType string, deployType string,
) (map[string][]DeployResult, error) { ) (map[string][]DeployResult, error) {
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex
results := make(map[string][]DeployResult) results := make(map[string][]DeployResult)
errCh := make(chan error, len(inventoryPaths)) errCh := make(chan error, len(inventoryPaths))
@@ -111,7 +150,9 @@ func (e *Executor) DeployParallel(
if err != nil { if err != nil {
errCh <- err errCh <- err
} }
mu.Lock()
results[p] = res results[p] = res
mu.Unlock()
}(path) }(path)
} }
+7 -10
View File
@@ -26,16 +26,13 @@ type Inventory struct {
Hosts []InventoryHost Hosts []InventoryHost
} }
const inventoryTemplateText = `{{ range .Hosts }} const inventoryTemplateText = `{{- range $i, $host := .Hosts }}
{{ .Name }} ansible_host={{ .IP }} ansible_port={{ .Port }} ansible_user={{ .User }} ansible_connection=ssh {{ $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 }}
{{ if eq .AuthMethod "key" }}ansible_ssh_private_key_file={{ .SSHKey }}{{ end }} deploy_type={{ $host.DeployType }}
{{ if eq .AuthMethod "password" }}ansible_ssh_pass={{ .Password }}{{ end }} agent_token={{ $host.Token }}
deploy_type={{ .DeployType }} agent_label={{ $host.Name }}
agent_token={{ .Token }} grpc_url={{ $host.GRPCURL }}
agent_label={{ .Name }} {{ end -}}`
grpc_url={{ .GRPCURL }}
{{ end }}`
// GenerateInventory generates an Ansible inventory file from the given hosts // GenerateInventory generates an Ansible inventory file from the given hosts
func GenerateInventory(hosts []InventoryHost, outputPath string) error { func GenerateInventory(hosts []InventoryHost, outputPath string) error {
+55 -18
View File
@@ -1,8 +1,7 @@
package ansible package ansible
// BinaryDeployPlaybook returns the Ansible playbook for binary deployment. // BinaryDeployPlaybook returns the Ansible playbook for binary deployment.
// Downloads the agent binary, writes config, and starts it directly (no systemd). // Downloads the agent binary, writes config, and installs a systemd unit for automatic restart.
// systemd unit is managed separately (e.g. via goreleaser .deb/.rpm packages).
const BinaryDeployPlaybook = `--- const BinaryDeployPlaybook = `---
- name: Deploy HellreigN Agent (Binary) - name: Deploy HellreigN Agent (Binary)
hosts: all hosts: all
@@ -14,6 +13,7 @@ const BinaryDeployPlaybook = `---
install_dir: /opt/hellreign install_dir: /opt/hellreign
bin_name: hellreign-agent bin_name: hellreign-agent
cert_dir: "{{ install_dir }}/certs" cert_dir: "{{ install_dir }}/certs"
gitea_releases_url: "{{ gitea_releases_url | default('https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download') }}"
tasks: tasks:
- name: Create installation directory - name: Create installation directory
@@ -30,7 +30,7 @@ const BinaryDeployPlaybook = `---
- name: Download HellreigN Agent binary - name: Download HellreigN Agent binary
get_url: get_url:
url: "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download/{{ bin_name }}" url: "{{ gitea_releases_url }}/{{ bin_name }}"
dest: "{{ install_dir }}/{{ bin_name }}" dest: "{{ install_dir }}/{{ bin_name }}"
mode: '0755' mode: '0755'
@@ -48,16 +48,43 @@ const BinaryDeployPlaybook = `---
dest: "{{ install_dir }}/config.yml" dest: "{{ install_dir }}/config.yml"
mode: '0644' mode: '0644'
- name: Start HellreigN Agent - name: Create systemd unit file
shell: | copy:
nohup {{ install_dir }}/{{ bin_name }} > /dev/null 2>&1 & content: |
echo $! [Unit]
args: Description=HellreigN Agent
executable: /bin/bash After=network-online.target
environment: Wants=network-online.target
CONFIG_FILE: "{{ install_dir }}/config.yml"
register: agent_pid [Service]
changed_when: true 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. // DockerDeployPlaybook returns the Ansible playbook for Docker deployment.
@@ -72,7 +99,9 @@ const DockerDeployPlaybook = `---
grpc_url: "{{ grpc_url | default('localhost:9001') }}" grpc_url: "{{ grpc_url | default('localhost:9001') }}"
container_name: hellreign-agent-{{ agent_label }} container_name: hellreign-agent-{{ agent_label }}
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest" image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
install_dir: /opt/hellreign
cert_dir: /etc/hellreign-agent/certs cert_dir: /etc/hellreign-agent/certs
config_dir: /etc/hellreign-agent
tasks: tasks:
- name: Install Docker (if not present) - name: Install Docker (if not present)
@@ -94,6 +123,12 @@ const DockerDeployPlaybook = `---
state: directory state: directory
mode: '0755' mode: '0755'
- name: Create configuration directory
file:
path: "{{ config_dir }}"
state: directory
mode: '0755'
- name: Pull HellreigN Agent image - name: Pull HellreigN Agent image
community.docker.docker_image: community.docker.docker_image:
name: "{{ image }}" name: "{{ image }}"
@@ -103,14 +138,15 @@ const DockerDeployPlaybook = `---
copy: copy:
content: | content: |
backend_url: "{{ backend_url }}" backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url }}" grpc_url: "{{ grpc_url | default('localhost:9001') }}"
label: "{{ agent_label }}" label: "{{ agent_label }}"
registration_token: "{{ agent_token }}" registration_token: "{{ agent_token }}"
cert_dir: "{{ cert_dir }}" cert_dir: "{{ cert_dir }}"
services: services:
- name: system - name: "{{ agent_label }}"
type: journald type: docker
dest: "{{ cert_dir }}/config.yml" path: "{{ container_name }}"
dest: "{{ config_dir }}/config.yml"
mode: '0644' mode: '0644'
- name: Create and run HellreigN Agent container - name: Create and run HellreigN Agent container
@@ -121,6 +157,7 @@ const DockerDeployPlaybook = `---
restart_policy: always restart_policy: always
volumes: volumes:
- "{{ cert_dir }}:/etc/hellreign-agent/certs" - "{{ cert_dir }}:/etc/hellreign-agent/certs"
- "{{ config_dir }}/config.yml:/etc/hellreign-agent/config.yml:ro"
env: env:
CONFIG_FILE: /etc/hellreign-agent/certs/config.yml CONFIG_FILE: /etc/hellreign-agent/config.yml
` `
+2 -3
View File
@@ -1,5 +1,4 @@
package ansible package ansible
const BaseInvTemplate = ` // This package contains embedded Ansible templates for playbooks and inventory generation.
// All templates are defined in playbooks.go and inventory.go.
`
+238
View File
@@ -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 ""
}
+135
View File
@@ -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)
}
+17 -41
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"sync"
"time" "time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
@@ -13,26 +12,19 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
) )
// Collector handles log streaming from connected agents.
type Collector struct { type Collector struct {
proto.UnimplementedCollectorServer proto.UnimplementedCollectorServer
logRepo *repository.LogRepository logRepo *repository.LogRepository
agents map[string]*Agent tracker *ConnTracker
mu sync.RWMutex
batchSize int batchSize int
flushInterval time.Duration flushInterval time.Duration
} }
type Agent struct { func New(logRepo *repository.LogRepository, tracker *ConnTracker) *Collector {
ID string
Label string
Services []string
ConnectedAt time.Time
}
func New(logRepo *repository.LogRepository) *Collector {
return &Collector{ return &Collector{
logRepo: logRepo, logRepo: logRepo,
agents: make(map[string]*Agent), tracker: tracker,
batchSize: 100, batchSize: 100,
flushInterval: 2 * time.Second, flushInterval: 2 * time.Second,
} }
@@ -56,27 +48,15 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
} }
service := serviceVals[0] service := serviceVals[0]
servicesVals := md["services"] agent := &Agent{
var services []string
if len(servicesVals) > 0 {
services = servicesVals
}
// Register agent
c.mu.Lock()
c.agents[agentName] = &Agent{
ID: agentName, ID: agentName,
Label: agentName, Label: agentName,
Services: services, Services: make([]Service, 0),
ConnectedAt: time.Now(), ConnectedAt: time.Now(),
} }
c.mu.Unlock()
defer func() { c.tracker.Register(agent)
c.mu.Lock() defer c.tracker.Unregister(agent.ID)
delete(c.agents, agentName)
c.mu.Unlock()
}()
log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service) log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service)
@@ -139,7 +119,6 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
for { for {
select { select {
case <-stream.Context().Done(): case <-stream.Context().Done():
// Context cancelled, flush remaining
_ = flush() _ = flush()
return stream.Context().Err() return stream.Context().Err()
case <-ticker.C: case <-ticker.C:
@@ -162,7 +141,6 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
} }
case err := <-errCh: case err := <-errCh:
if err == io.EOF { if err == io.EOF {
// Client closed stream
return flush() return flush()
} }
return fmt.Errorf("failed to receive: %w", err) return fmt.Errorf("failed to receive: %w", err)
@@ -170,19 +148,17 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
} }
} }
// GetAgent delegates to the tracker.
func (c *Collector) GetAgent(name string) (*Agent, bool) { func (c *Collector) GetAgent(name string) (*Agent, bool) {
c.mu.RLock() return c.tracker.GetAgent(name)
defer c.mu.RUnlock()
a, ok := c.agents[name]
return a, ok
} }
// Agents delegates to the tracker.
func (c *Collector) Agents() []*Agent { func (c *Collector) Agents() []*Agent {
c.mu.RLock() return c.tracker.Agents()
defer c.mu.RUnlock() }
result := make([]*Agent, 0, len(c.agents))
for _, a := range c.agents { // GetSystemMetrics delegates to the tracker.
result = append(result, a) func (c *Collector) GetSystemMetrics() map[string]AgentMetricsInfo {
} return c.tracker.GetSystemMetrics()
return result
} }
@@ -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
}
+122 -67
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"log"
"sync" "sync"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
@@ -11,27 +12,30 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/stats"
) )
// Commander handles command execution on connected agents.
type Commander struct { type Commander struct {
proto.UnimplementedCommanderServer proto.UnimplementedCommanderServer
agents map[string]Agent tracker *ConnTracker
mu sync.RWMutex jobber Jobber
jobber Jobber
} }
// Jobber persists job state.
type Jobber interface { type Jobber interface {
InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error) InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error)
UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error)
} }
func New(jobber Jobber) *Commander { func New(jobber Jobber, tracker *ConnTracker) *Commander {
return &Commander{ return &Commander{
agents: make(map[string]Agent), jobber: jobber,
jobber: jobber, tracker: tracker,
} }
} }
// Agent represents a connected agent with an active bidirectional stream.
type Agent struct { type Agent struct {
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command] bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]
in chan *proto.Command in chan *proto.Command
@@ -40,10 +44,11 @@ type Agent struct {
ctx context.Context ctx context.Context
aid string aid string
Token string // agent id Token string
Label string Label string
Services []string Services []string
} }
type JobOut struct { type JobOut struct {
fc models.Job fc models.Job
err error err error
@@ -53,61 +58,91 @@ type Job struct {
out chan JobOut out chan JobOut
} }
func (self *Commander) GetAgent(aid string) (agent Agent, ok bool) { // ConnTracker tracks connected agents and handles cleanup on disconnect.
self.mu.RLock() // It implements grpc.StatsHandler for disconnect detection.
defer self.mu.RUnlock() type ConnTracker struct {
agent, ok = self.agents[aid] mu sync.RWMutex
return agents map[string]*Agent
} }
// GetAgentByLabel searches for an agent by its human-readable label. // GetAgentByLabel searches for an agent by its human-readable label.
func (self *Commander) GetAgentByLabel(label string) (agent Agent, ok bool) { func (self *ConnTracker) GetAgentByLabel(label string) (agent Agent, ok bool) {
self.mu.RLock() self.mu.RLock()
defer self.mu.RUnlock() defer self.mu.RUnlock()
for _, a := range self.agents { for _, a := range self.agents {
if a.Label == label { if a.Label == label {
return a, true return *a, true
} }
} }
return return
} }
func (self *Commander) Agents() []Agent { func NewConnTracker() *ConnTracker {
self.mu.RLock() return &ConnTracker{
defer self.mu.RUnlock() agents: make(map[string]*Agent),
result := make([]Agent, 0, len(self.agents)) }
for _, a := range self.agents { }
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) result = append(result, a)
} }
return result return result
} }
func (self *Commander) removeAgent(aid string) { // grpc.StatsHandler implementation.
self.mu.Lock()
defer self.mu.Unlock() func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
delete(self.agents, aid) return ctx
} }
func (self *Agent) AddJob(job models.JobForInsert) (int64, error) { func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
jid, err := self.jobber.InitJob(self.ctx, self.aid, job)
if err != nil { func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
return 0, err 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])
} }
self.jobs[jid] = newJob()
self.in <- &proto.Command{
Id: jid,
Command: job.Command,
Stdin: job.Stdin,
}
return jid, err
} }
func (self *Agent) WaitJob(jid int64) (*models.Job, error) { // Stream handles a new agent connection and runs the send/recv loops.
result := <-self.jobs[jid].out func (c *Commander) Stream(
return &result.fc, result.err
}
func (self *Commander) Stream(
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command],
) error { ) error {
md, ok := metadata.FromIncomingContext(bidi.Context()) md, ok := metadata.FromIncomingContext(bidi.Context())
@@ -121,35 +156,58 @@ func (self *Commander) Stream(
aid := aidVals[0] aid := aidVals[0]
var label string var label string
labelVals := md["label"] if vals := md["label"]; len(vals) > 0 {
if len(labelVals) > 0 { label = vals[0]
label = labelVals[0]
} }
agent := newAgent(bidi, self.jobber, aid, label) agent := NewAgent(bidi.Context(), c.jobber, aid, label)
self.mu.Lock() agent.bidi = bidi
self.agents[aid] = agent
self.mu.Unlock() c.tracker.Register(aid, agent)
defer c.tracker.Unregister(aid)
defer self.removeAgent(aid)
return agent.run() return agent.run()
} }
func (self *Agent) run() error { // 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 := new(errgroup.Group)
wg.Go(self.recv) wg.Go(a.recv)
wg.Go(self.send) wg.Go(a.send)
return wg.Wait() return wg.Wait()
} }
func (self *Agent) recv() error { func (a *Agent) recv() error {
for { for {
job, err := func() (job models.Job, err error) { job, err := func() (job models.Job, err error) {
msg, err := self.bidi.Recv() msg, err := a.bidi.Recv()
if err != nil { if err != nil {
return return
} }
return self.jobber.UpdateJobInDB(self.ctx, msg.Id, models.JobForUpdate{ return a.jobber.UpdateJobInDB(a.ctx, msg.Id, models.JobForUpdate{
Stdout: msg.Stdout, Stdout: msg.Stdout,
Stderr: msg.Stderr, Stderr: msg.Stderr,
Status: msg.Status, Status: msg.Status,
@@ -158,8 +216,7 @@ func (self *Agent) recv() error {
if err == io.EOF { if err == io.EOF {
return nil return nil
} }
// TODO: that would blow up at some point out := a.jobs[job.ID].out
out := self.jobs[job.ID].out
out <- JobOut{ out <- JobOut{
fc: job, fc: job,
err: err, err: err,
@@ -168,28 +225,26 @@ func (self *Agent) recv() error {
} }
} }
func (self *Agent) send() error { func (a *Agent) send() error {
for job := range self.in { for job := range a.in {
if err := self.bidi.Send(job); err != nil { if err := a.bidi.Send(job); err != nil {
return err return err
} }
} }
return io.EOF return io.EOF
// self.jobs[]
} }
func newAgent( func NewAgent(
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], ctx context.Context,
jobber Jobber, jobber Jobber,
aid string, aid string,
label string, label string,
) Agent { ) *Agent {
return Agent{ return &Agent{
bidi: bidi, in: make(chan *proto.Command, 10),
in: make(chan *proto.Command),
jobs: make(map[int64]Job), jobs: make(map[int64]Job),
jobber: jobber, jobber: jobber,
ctx: bidi.Context(), ctx: ctx,
aid: aid, aid: aid,
Label: label, Label: label,
Token: aid, Token: aid,
+66 -5
View File
@@ -29,22 +29,33 @@ func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
grpcPort = "9001" grpcPort = "9001"
} }
grpcHost := os.Getenv("GRPC_SERVER_HOST")
if grpcHost == "" {
grpcHost = "0.0.0.0"
}
backendURL := os.Getenv("BACKEND_URL") backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" { if backendURL == "" {
backendURL = "http://localhost:8080" 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{ exec := ansible.NewExecutor(ansible.ExecutorConfig{
WorkDir: workDir, WorkDir: workDir,
GRPCServerHost: "0.0.0.0", // TODO: make configurable GRPCServerHost: grpcHost,
GRPCServerPort: grpcPort, GRPCServerPort: grpcPort,
BackendURL: backendURL, BackendURL: backendURL,
GiteaReleasesURL: giteaURL,
}) })
// Write playbooks on init // Write playbooks on init
if err := exec.WriteAllPlaybooks(); err != nil { if err := exec.WriteAllPlaybooks(); err != nil {
// Log but don't fail - playbooks can be written later // Log the error - deployment will fail later if playbooks can't be written
_ = err fmt.Fprintf(os.Stderr, "WARNING: failed to write Ansible playbooks: %v\n", err)
} }
return &AgentDeployGroup{ return &AgentDeployGroup{
@@ -72,6 +83,48 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
return 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 // Create work directory
workDir := adg.executor.WorkDir() workDir := adg.executor.WorkDir()
if err := os.MkdirAll(workDir, 0755); err != nil { if err := os.MkdirAll(workDir, 0755); err != nil {
@@ -123,6 +176,8 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i)) inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil { if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
// Rollback: delete the token we just created
_ = adg.Repo.DeleteRegistrationToken(token)
results = append(results, repository.DeployResult{ results = append(results, repository.DeployResult{
IP: server.IP, IP: server.IP,
AgentLabel: server.AgentLabel, AgentLabel: server.AgentLabel,
@@ -136,10 +191,14 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
// Run Ansible playbook for this server // Run Ansible playbook for this server
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType)) deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
// Clean up inventory file // Clean up inventory file (log error but don't fail deployment)
os.Remove(inventoryPath) 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 { if err != nil {
// Rollback: delete the token since deployment failed
_ = adg.Repo.DeleteRegistrationToken(token)
results = append(results, repository.DeployResult{ results = append(results, repository.DeployResult{
IP: server.IP, IP: server.IP,
AgentLabel: server.AgentLabel, AgentLabel: server.AgentLabel,
@@ -155,6 +214,8 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
if len(deployResults) > 0 && !deployResults[0].Success { if len(deployResults) > 0 && !deployResults[0].Success {
success = false success = false
errMsg = deployResults[0].Stderr errMsg = deployResults[0].Stderr
// Rollback: delete the token since ansible playbook reported failure
_ = adg.Repo.DeleteRegistrationToken(token)
} }
results = append(results, repository.DeployResult{ results = append(results, repository.DeployResult{
+47 -1
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
@@ -36,13 +37,58 @@ func (ag *AgentsGroup) List(c *gin.Context) {
agents := make([]AgentInfo, 0) agents := make([]AgentInfo, 0)
for _, agent := range ag.collector.Agents() { 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{ agents = append(agents, AgentInfo{
Token: agent.ID, Token: agent.ID,
Label: agent.Label, Label: agent.Label,
Services: agent.Services, Services: services,
ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"), ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"),
}) })
} }
c.JSON(http.StatusOK, agents) 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)
}
+385
View File
@@ -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"
}
+187 -50
View File
@@ -4,30 +4,44 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os/exec" "strconv"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type JobsHandlers struct { type JobsHandlers struct {
cmder *commander.Commander tracker *commander.ConnTracker
svc *service.ScriptService svc *service.ScriptService
whereami string
jobRepo *repository.JobRepository
} }
func NewJobsHandlers(cmder *commander.Commander, svc *service.ScriptService) JobsHandlers { func NewJobsHandlers(tracker *commander.ConnTracker, svc *service.ScriptService, whereami string, jobRepo *repository.JobRepository) JobsHandlers {
return JobsHandlers{cmder: cmder, svc: svc} return JobsHandlers{tracker: tracker, svc: svc, whereami: whereami, jobRepo: jobRepo}
} }
// AddJobIn is the request body for creating a job.
type AddJobIn struct { type AddJobIn struct {
Command string `json:"command" binding:"required"` Command string `json:"command" binding:"required"`
InterpreterID int64 `json:"interpreter_id"` InterpreterID int64 `json:"interpreter_id"`
Stdin *string `json:"stdin"` Stdin *string `json:"stdin"`
AgentID string `json:"agent_id" binding:"required"` AgentID string `json:"agent_id" binding:"required"`
} }
// AddJobOut is the response body for a submitted job.
type AddJobOut struct { 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"` ID int64 `json:"id"`
Command []string `json:"command"` Command []string `json:"command"`
Stdin *string `json:"stdin"` Stdin *string `json:"stdin"`
@@ -36,65 +50,188 @@ type AddJobOut struct {
Status int32 `json:"status"` Status int32 `json:"status"`
} }
// AddJob creates and executes a job on a target agent. // AddJob submits a job to an agent and returns a wait_url for the result.
// @Summary Create and run a job on an agent // @Summary Submit a job to an agent
// @Description Sends a command to the specified agent, waits for execution, and returns the result // @Description Sends a command to the specified agent and returns a URL to wait for the result
// @Tags jobs // @Tags jobs
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body AddJobIn true "Job request" // @Param body body AddJobIn true "Job request"
// @Success 201 {object} AddJobOut // @Success 201 {object} AddJobOut
// @Security Bearer
// @Router /jobs [post] // @Router /jobs [post]
func (self *JobsHandlers) AddJob(c *gin.Context) { func (h *JobsHandlers) AddJob(c *gin.Context) {
err := func() error { var in AddJobIn
var in AddJobIn if err := c.Bind(&in); err != nil {
if err := c.Bind(&in); err != nil { c.Error(err)
return err return
} }
agent, ok := self.cmder.GetAgent(in.AgentID)
if !ok {
c.Status(http.StatusNotFound)
return fmt.Errorf("agent not found")
}
var command []string result, err := h.runCommand(c, in.AgentID, in.InterpreterID, in.Command, in.Stdin)
if in.InterpreterID == 0 { if err != nil {
command = []string{"sh", "-c", in.Command} c.Error(err)
} else { return
var err error }
command, err = self.svc.ResolveCommand(
c.Request.Context(),
in.InterpreterID,
in.Command,
)
if err != nil {
return err
}
}
jid, err := agent.AddJob(models.JobForInsert{ c.JSON(http.StatusCreated, result)
Command: command, }
Stdin: in.Stdin,
}) // runCommand resolves command, submits a job to the agent, and returns AddJobOut.
if err != nil { // Shared between jobs and scripts handlers.
return err 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
} }
job, err := agent.WaitJob(jid) c.Error(err)
if err != nil && !errors.As(err, &exec.ExitError{}) { return
return err }
}
c.JSON(http.StatusCreated, AddJobOut{ // 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, ID: job.ID,
Command: job.Command, Command: job.Command,
Stdin: job.Stdin, Stdin: job.Stdin,
Stdout: job.Stdout, Stdout: *job.Stdout,
Stderr: job.Stderr, Stderr: *job.Stderr,
Status: job.Status, Status: *job.Status,
}) })
return nil 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 { if err != nil {
c.Error(err) 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,
})
} }
+48 -68
View File
@@ -13,12 +13,13 @@ import (
) )
type ScriptHandlers struct { type ScriptHandlers struct {
svc *service.ScriptService svc *service.ScriptService
cmder *commander.Commander tracker *commander.ConnTracker
whereami string
} }
func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) ScriptHandlers { func NewScriptHandlers(svc *service.ScriptService, tracker *commander.ConnTracker, whereami string) ScriptHandlers {
return ScriptHandlers{svc: svc, cmder: cmder} return ScriptHandlers{svc: svc, tracker: tracker, whereami: whereami}
} }
type RunScriptIn struct { type RunScriptIn struct {
@@ -28,73 +29,52 @@ type RunScriptIn struct {
Stdin *string `json:"stdin"` Stdin *string `json:"stdin"`
} }
type RunScriptOut struct { // RunScript submits a script as a job and returns a wait_url for the result.
ID int64 `json:"id"`
Command []string `json:"command"`
Stdin *string `json:"stdin"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int32 `json:"status"`
}
// RunScript executes a script on a target agent.
// @Summary Run a script on an agent // @Summary Run a script on an agent
// @Description Resolves interpreter argv[] and sends the full command to the agent // @Description Resolves interpreter argv[] and sends the full command to the agent
// @Tags scripts // @Tags scripts
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body RunScriptIn true "Script request" // @Param body body RunScriptIn true "Script request"
// @Success 201 {object} RunScriptOut // @Success 201 {object} AddJobOut
// @Security Bearer // @Security Bearer
// @Router /scripts/run [post] // @Router /scripts/run [post]
func (self *ScriptHandlers) RunScript(c *gin.Context) { func (h *ScriptHandlers) RunScript(c *gin.Context) {
err := func() error { var in RunScriptIn
var in RunScriptIn if err := c.Bind(&in); err != nil {
if err := c.Bind(&in); err != nil { c.Error(err)
return err return
} }
command, err := self.svc.ResolveCommand( agent, ok := h.tracker.GetAgent(in.AgentID)
c.Request.Context(), if !ok {
in.InterpreterID, c.Status(http.StatusNotFound)
in.ScriptText, c.Error(fmt.Errorf("agent not found"))
) return
if err != nil { }
return err
}
agent, ok := self.cmder.GetAgent(in.AgentID) command, err := h.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText)
if !ok {
c.Status(http.StatusNotFound)
return fmt.Errorf("agent not found")
}
jid, err := agent.AddJob(models.JobForInsert{
Command: command,
Stdin: in.Stdin,
})
if err != nil {
return err
}
job, err := agent.WaitJob(jid)
if err != nil {
return err
}
c.JSON(http.StatusCreated, RunScriptOut{
ID: job.ID,
Command: job.Command,
Stdin: job.Stdin,
Stdout: job.Stdout,
Stderr: job.Stderr,
Status: job.Status,
})
return nil
}()
if err != nil { if err != nil {
c.Error(err) 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. // ListInterpreters returns all registered script interpreters.
@@ -105,8 +85,8 @@ func (self *ScriptHandlers) RunScript(c *gin.Context) {
// @Success 200 {array} repository.ScriptInterpreter // @Success 200 {array} repository.ScriptInterpreter
// @Security Bearer // @Security Bearer
// @Router /scripts/interpreters [get] // @Router /scripts/interpreters [get]
func (self *ScriptHandlers) ListInterpreters(c *gin.Context) { func (h *ScriptHandlers) ListInterpreters(c *gin.Context) {
interpreters, err := self.svc.List(c.Request.Context()) interpreters, err := h.svc.List(c.Request.Context())
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -124,14 +104,14 @@ func (self *ScriptHandlers) ListInterpreters(c *gin.Context) {
// @Success 201 {object} repository.ScriptInterpreter // @Success 201 {object} repository.ScriptInterpreter
// @Security Bearer // @Security Bearer
// @Router /scripts/interpreters [post] // @Router /scripts/interpreters [post]
func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) { func (h *ScriptHandlers) CreateInterpreter(c *gin.Context) {
var in repository.ScriptInterpreterCreate var in repository.ScriptInterpreterCreate
if err := c.BindJSON(&in); err != nil { if err := c.BindJSON(&in); err != nil {
c.Error(err) c.Error(err)
return return
} }
si, err := self.svc.Create(c.Request.Context(), in) si, err := h.svc.Create(c.Request.Context(), in)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -148,14 +128,14 @@ func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) {
// @Success 200 {object} repository.ScriptInterpreter // @Success 200 {object} repository.ScriptInterpreter
// @Security Bearer // @Security Bearer
// @Router /scripts/interpreters/:id [get] // @Router /scripts/interpreters/:id [get]
func (self *ScriptHandlers) GetInterpreter(c *gin.Context) { func (h *ScriptHandlers) GetInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
} }
si, err := self.svc.GetByID(c.Request.Context(), id) si, err := h.svc.GetByID(c.Request.Context(), id)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -174,7 +154,7 @@ func (self *ScriptHandlers) GetInterpreter(c *gin.Context) {
// @Success 200 {object} repository.ScriptInterpreter // @Success 200 {object} repository.ScriptInterpreter
// @Security Bearer // @Security Bearer
// @Router /scripts/interpreters/:id [put] // @Router /scripts/interpreters/:id [put]
func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) { func (h *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
@@ -187,7 +167,7 @@ func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
return return
} }
si, err := self.svc.Update(c.Request.Context(), id, in) si, err := h.svc.Update(c.Request.Context(), id, in)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -203,14 +183,14 @@ func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
// @Success 204 // @Success 204
// @Security Bearer // @Security Bearer
// @Router /scripts/interpreters/:id [delete] // @Router /scripts/interpreters/:id [delete]
func (self *ScriptHandlers) DeleteInterpreter(c *gin.Context) { func (h *ScriptHandlers) DeleteInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64) id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
} }
if err := self.svc.Delete(c.Request.Context(), id); err != nil { if err := h.svc.Delete(c.Request.Context(), id); err != nil {
c.Error(err) c.Error(err)
return return
} }
+273 -19
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
@@ -15,13 +16,14 @@ import (
// ScriptHandlersGroup handles script management routes. // ScriptHandlersGroup handles script management routes.
type ScriptHandlersGroup struct { type ScriptHandlersGroup struct {
svc *service.ScriptService svc *service.ScriptService
cmder *commander.Commander cmder *commander.Commander
whereami string
} }
// NewScriptHandlersGroup creates a new ScriptHandlersGroup. // NewScriptHandlersGroup creates a new ScriptHandlersGroup.
func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander) *ScriptHandlersGroup { func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander, whereami string) *ScriptHandlersGroup {
return &ScriptHandlersGroup{svc: svc, cmder: cmder} return &ScriptHandlersGroup{svc: svc, cmder: cmder, whereami: whereami}
} }
// GetTree returns the script directory tree. // GetTree returns the script directory tree.
@@ -63,6 +65,12 @@ func (sh *ScriptHandlersGroup) CreateScript(c *gin.Context) {
return 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) script, err := sh.svc.Repo.CreateScript(req)
if err != nil { if err != nil {
if isUniqueConstraint(err) { if isUniqueConstraint(err) {
@@ -133,6 +141,14 @@ func (sh *ScriptHandlersGroup) UpdateScript(c *gin.Context) {
return 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) script, err := sh.svc.Repo.UpdateScript(id, req)
if err != nil { if err != nil {
if errors.Is(err, repository.ErrNotFound) { if errors.Is(err, repository.ErrNotFound) {
@@ -177,13 +193,13 @@ func (sh *ScriptHandlersGroup) DeleteScript(c *gin.Context) {
// RunScriptByID executes a stored script on a target agent. // RunScriptByID executes a stored script on a target agent.
// @Summary Run script by ID // @Summary Run script by ID
// @Description Loads a script from storage, resolves interpreter command, and executes on the specified agent // @Description Loads a script from storage, resolves interpreter command, and submits it to the agent
// @Tags scripts // @Tags scripts
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path int true "Script ID" // @Param id path int true "Script ID"
// @Param body body RunStoredScriptIn true "Agent token and optional stdin" // @Param body body RunStoredScriptIn true "Agent ID and optional stdin"
// @Success 201 {object} RunScriptOut // @Success 201 {object} AddJobOut
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string // @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string // @Failure 500 {object} map[string]string
@@ -233,19 +249,12 @@ func (sh *ScriptHandlersGroup) RunScriptByID(c *gin.Context) {
return return
} }
job, err := agent.WaitJob(jid) waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", sh.whereami, jid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("job execution failed: %v", err)})
return
}
c.JSON(http.StatusCreated, RunScriptOut{ c.JSON(http.StatusCreated, AddJobOut{
ID: job.ID, ID: jid,
Command: job.Command, Command: command,
Stdin: job.Stdin, WaitURL: waitURL,
Stdout: job.Stdout,
Stderr: job.Stderr,
Status: job.Status,
}) })
} }
@@ -255,6 +264,226 @@ type RunStoredScriptIn struct {
Stdin *string `json:"stdin"` 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. // isUniqueConstraint checks if the error is a SQLite UNIQUE constraint violation.
func isUniqueConstraint(err error) bool { func isUniqueConstraint(err error) bool {
return err != nil && (err.Error() != "" && contains(err.Error(), "UNIQUE constraint")) return err != nil && (err.Error() != "" && contains(err.Error(), "UNIQUE constraint"))
@@ -272,3 +501,28 @@ func searchSubstring(s, substr string) bool {
} }
return false 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
}
+10 -5
View File
@@ -1,10 +1,8 @@
package models package models
type Job struct { type JobBase struct {
ID int64 ID int64
AgentID string
JobForInsert
JobForUpdate
} }
type JobForInsert struct { type JobForInsert struct {
Command []string Command []string
@@ -15,3 +13,10 @@ type JobForUpdate struct {
Stderr string Stderr string
Status int32 Status int32
} }
type Job struct {
JobBase
JobForInsert
Stdout *string
Stderr *string
Status *int32
}
+33 -2
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
@@ -86,9 +87,9 @@ func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job,
var stdinVal *string var stdinVal *string
err := r.DB.QueryRowContext(ctx, err := r.DB.QueryRowContext(ctx,
`SELECT id, command, stdin, stdout, stderr, status FROM jobs WHERE id = ?`, `SELECT id, agent_id, command, stdin, stdout, stderr, status FROM jobs WHERE id = ?`,
jid, jid,
).Scan(&job.ID, &commandJSON, &stdinVal, &job.Stdout, &job.Stderr, &job.Status) ).Scan(&job.ID, &job.AgentID, &commandJSON, &stdinVal, &job.Stdout, &job.Stderr, &job.Status)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return models.Job{}, ErrNotFound return models.Job{}, ErrNotFound
@@ -103,3 +104,33 @@ func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job,
job.Stdin = stdinVal job.Stdin = stdinVal
return job, nil 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
}
@@ -299,6 +299,12 @@ func (r *Repository) MarkRegistrationTokenUsed(token string) error {
return nil 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. // ActivateToken activates a user by token value.
func (r *Repository) ActivateToken(token string) error { func (r *Repository) ActivateToken(token string) error {
result, err := r.DB.Exec( result, err := r.DB.Exec(
+20 -1
View File
@@ -38,6 +38,7 @@ type treeNode struct {
// BuildTree builds a directory tree from all scripts in the database. // 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"). // 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) { func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) {
scripts, err := s.Repo.ListScripts() scripts, err := s.Repo.ListScripts()
if err != nil { if err != nil {
@@ -49,10 +50,15 @@ func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) {
for _, sc := range scripts { for _, sc := range scripts {
parts := strings.Split(sc.Path, "/") 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 // Walk through path parts, creating folders as needed
currentMap := root currentMap := root
for i, part := range parts { for i, part := range parts {
isFile := i == len(parts)-1 isLastPart := i == len(parts)-1
isFile := isLastPart && !isPlaceholder
if _, exists := currentMap[part]; !exists { if _, exists := currentMap[part]; !exists {
node := &treeNode{ node := &treeNode{
name: part, name: part,
@@ -70,6 +76,19 @@ func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) {
node.typ = "folder" node.typ = "folder"
} }
currentMap[part] = node 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 currentMap = currentMap[part].children
} }
+294
View File
@@ -81,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);
`
+7
View File
@@ -49,5 +49,12 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("migrate scripts: %w", err) 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
} }
+3
View File
@@ -0,0 +1,3 @@
# Backend API URL. По умолчанию "/api/v1" (через nginx proxy).
# Для локальной разработки: "http://localhost:8080/api/v1"
VITE_API_BASE_URL=/api/v1
+3 -1
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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;
} }
+7152
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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": {
+13
View File
@@ -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 />
+247
View File
@@ -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}</>;
}; };
+190 -9
View File
@@ -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="/auth" element={<AuthPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/themes" element={<ThemesPage />} />
<Route path="/add-agents" element={<AddAgentsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <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>
<Route path="/test" element={<TestPage />} />
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</ReactRoutes> </ReactRoutes>
</Suspense> </Suspense>
); );
+166
View File
@@ -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>
);
};
+4
View File
@@ -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",
});
}
},
}));
+15
View File
@@ -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 };
}
+26
View File
@@ -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>
);
};
+51 -3
View File
@@ -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",
}} }}
> >
+16 -10
View File
@@ -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,
login: data.login, ): Promise<Record<string, string>> => {
password: data.password, const response = await apiClient.post<Record<string, string>>(
name: data.firstName, "/auth/register",
last_name: data.lastName, {
}); login: data.login,
password: data.password,
name: data.firstName,
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>
);
};
@@ -0,0 +1,96 @@
// modules/dashboard/Dashboard.tsx
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useEffect, useRef, useState } from "react";
import { useDashboardStore } from "./store/dashboard.store";
import { useAuthStore } from "../auth/store/useAuthStore";
import { ChartWidget } from "./components/chart,widget";
import { AddWidgetButton } from "./components/add.widget.button";
import { AddWidgetModal } from "./components/add.widget.modal";
import { WidgetSettings } from "./components/chart.settings";
import { useWidgets } from "./hooks/use.widget";
export const Dashboard: React.FC = () => {
const { chartData, loading, error, fetchMetrics, clearData } =
useDashboardStore();
// const { servicesQueryParams } = useAgentStore();
const intervalRef = useRef<number | null>(null);
const { token } = useAuthStore();
// Первичная загрузка (не latest)
// const fetchPrimaryData = () => {
// fetchMetrics(false, token || "", servicesQueryParams, { since: "10m" });
// };
// Периодическое обновление (latest)
// const fetchLatestData = () => {
// fetchMetrics(true, token || "", servicesQueryParams);
// };
// useEffect(() => {
// fetchPrimaryData();
// }, []);
// useEffect(() => {
// intervalRef.current = window.setInterval(() => {
// fetchLatestData();
// }, 30000);
// return () => {
// if (intervalRef.current) {
// window.clearInterval(intervalRef.current);
// }
// clearData();
// };
// }, [servicesQueryParams]);
const { widgets, addWidget, updateWidget, removeWidget, toggleVisibility } =
useWidgets();
const [editingWidget, setEditingWidget] = useState<any>(null);
const [isAdding, setIsAdding] = useState(false);
const visibleWidgets = widgets.filter((w) => w.visible);
return (
<div className="p-4">
{loading && chartData.length === 0 ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-primary border-t-transparent" />
</div>
) : error ? (
<div className="flex items-center justify-center h-40">
<span className="text-[10px] text-red-500">{error}</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
{visibleWidgets.map((widget) => (
<ChartWidget
key={widget.id}
widget={widget}
data={chartData}
onEdit={() => setEditingWidget(widget)}
onToggleVisibility={() => toggleVisibility(widget.id)}
/>
))}
</div>
)}
<AddWidgetButton onClick={() => setIsAdding(true)} />
<AddWidgetModal
isOpen={isAdding}
onAdd={addWidget}
onClose={() => setIsAdding(false)}
/>
{editingWidget && (
<WidgetSettings
widget={editingWidget}
onUpdate={updateWidget}
onRemove={() => removeWidget(editingWidget.id)}
onClose={() => setEditingWidget(null)}
/>
)}
</div>
);
};
@@ -0,0 +1,72 @@
import { useState } from "react";
import type { ChartType, ChartWidget } from "../types";
const initialWidgets: ChartWidget[] = [
{
id: "1",
type: "line",
title: "Линии",
dataKey: "chart-line",
visible: true,
},
{
id: "2",
type: "bar",
title: "Столбцы",
dataKey: "chart-bar",
visible: true,
},
{
id: "3",
type: "area",
title: "Закрашенные линии",
dataKey: "chart-area",
visible: true,
},
{
id: "4",
type: "pie",
title: "Круговая диаграмма",
dataKey: "chart-pie",
visible: true,
},
];
export const useWidgets = () => {
const [widgets, setWidgets] = useState<ChartWidget[]>(initialWidgets);
const addWidget = (data: {
type: ChartType;
title: string;
dataKey: string;
}) => {
const newWidget: ChartWidget = {
id: Date.now().toString(),
...data,
visible: true,
};
setWidgets([...widgets, newWidget]);
};
const updateWidget = (updated: ChartWidget) => {
setWidgets(widgets.map((w) => (w.id === updated.id ? updated : w)));
};
const removeWidget = (id: string) => {
setWidgets(widgets.filter((w) => w.id !== id));
};
const toggleVisibility = (id: string) => {
setWidgets(
widgets.map((w) => (w.id === id ? { ...w, visible: !w.visible } : w)),
);
};
return {
widgets,
addWidget,
updateWidget,
removeWidget,
toggleVisibility,
};
};
@@ -0,0 +1,129 @@
import { create } from "zustand";
import { apiService } from "@/shared/api/api.service";
import type { MetricData } from "../types";
interface DashboardState {
chartData: MetricData[];
loading: boolean;
error: string | null;
fetchMetrics: (
isLatest: boolean,
token: string,
queryParams?: string,
extraParams?: Record<string, string>,
) => Promise<void>;
clearData: () => void;
}
export const useDashboardStore = create<DashboardState>((set, get) => {
const convertPrimaryData = (response: any) => {
set((state) => {
if (!response.intervals || !Array.isArray(response.intervals))
return { chartData: state.chartData };
const newData = [...state.chartData];
response.intervals.forEach((interval: any) => {
const newPoint: MetricData = {
timestamp: new Date(interval.timestamp).toLocaleTimeString(),
};
if (interval.group_by && Array.isArray(interval.group_by)) {
interval.group_by.forEach((item: any) => {
newPoint[item.value] = item.count;
});
}
newData.push(newPoint);
});
return { chartData: newData.slice(-20) };
});
};
const convertSingleData = (response: any) => {
set((state) => {
const newPoint: MetricData = {
timestamp: new Date().toLocaleTimeString(),
};
if (Array.isArray(response)) {
response.forEach((item: any) => {
newPoint[item.value] = item.count;
});
} else if (response.groupBy && Array.isArray(response.groupBy)) {
response.groupBy.forEach((item: any) => {
newPoint[item.value] = item.count;
});
}
const updatedData = [...state.chartData, newPoint].slice(-20);
return { chartData: updatedData };
});
};
const fetchMetrics = async (
isLatest: boolean,
token: string,
queryParams?: string,
extraParams?: Record<string, string>,
) => {
set({ loading: true, error: null });
try {
let endpoint = isLatest
? "logs/aggregations/latest"
: "logs/aggregations";
// Если есть queryParams, добавляем его к эндпоинту
if (queryParams && queryParams.trim() !== "") {
endpoint = `${endpoint}?${queryParams}`;
}
const params: Record<string, string> = {
agg: "count",
groupby: "level",
...extraParams,
};
const result = await apiService.get<any>(endpoint, {
params,
headers: {
Authorization: `bearer ${token}`,
},
});
if (result) {
if (isLatest) {
convertSingleData(result);
} else {
convertPrimaryData(result);
}
}
} catch (error) {
console.error(
`Failed to fetch ${isLatest ? "latest" : "primary"} metrics:`,
error,
);
set({
error: error instanceof Error ? error.message : "Ошибка запроса",
});
} finally {
set({ loading: false });
}
};
const clearData = () => {
set({ chartData: [], error: null });
};
return {
chartData: [],
loading: false,
error: null,
fetchMetrics,
clearData,
setChartData: (data: MetricData[]) =>
set({ chartData: data, loading: false }),
};
});
+22
View File
@@ -0,0 +1,22 @@
export type ChartType = "line" | "bar" | "area" | "pie";
export interface ChartWidget {
id: string;
type: ChartType;
title: string;
dataKey: string;
visible: boolean;
}
export interface MetricData {
timestamp: string;
[key: string]: number | string;
}
export interface StatsItem {
label: string;
key: string;
icon: string;
color: string;
suffix?: string;
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useRef, useEffect, useState } from "react";
import type {
GraphData,
GraphNode,
GraphLink,
ContextMenuState,
} from "./types";
import { useGraphStore } from "./store/useGraphStore";
import {
ForceGraph,
GraphControls,
GraphContextMenu,
GraphStatusBar,
GraphStats,
} from "./components";
interface GraphProps {
initialData?: GraphData;
onExport?: () => void;
onDataChange?: (data: GraphData) => void;
}
export const Graph: React.FC<GraphProps> = ({
initialData,
onExport,
onDataChange,
}) => {
const fgRef = useRef<any>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const data = useGraphStore((s) => s.data);
const isLinkMode = useGraphStore((s) => s.isLinkMode);
const selectedNode = useGraphStore((s) => s.selectedNode);
const setData = useGraphStore((s) => s.setData);
// Инициализация данных
useEffect(() => {
if (initialData) setData(initialData);
}, [initialData, setData]);
// Закрыть контекстное меню по клику вне
useEffect(() => {
const handleClickOutside = () => setContextMenu(null);
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
const handleNodeRightClick = (node: GraphNode, event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ x: event.clientX, y: event.clientY, node, link: null });
};
if (!data || data.nodes.length === 0) {
return (
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
</div>
</div>
</div>
);
}
return (
<div
className="p-4 h-full flex flex-col"
style={{ backgroundColor: "var(--card-bg)" }}
>
{/* Статистика сверху */}
<GraphStats data={data} />
{/* Граф */}
<div
className="flex-1 rounded-lg overflow-hidden relative mt-2"
style={{ border: "1px solid var(--border)" }}
>
<ForceGraph
ref={fgRef}
data={data}
onNodeRightClick={handleNodeRightClick}
/>
<GraphContextMenu
menu={contextMenu}
data={data}
onClose={() => setContextMenu(null)}
/>
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
</div>
{/* Кнопки снизу */}
<GraphControls
fgRef={fgRef}
onExport={onExport}
onDataChange={onDataChange}
/>
</div>
);
};
export default Graph;
@@ -0,0 +1,229 @@
import React, {
useRef,
useEffect,
useCallback,
useState,
forwardRef,
} from "react";
import ForceGraph2D from "react-force-graph-2d";
import type { GraphData, GraphNode, GraphLink } from "../types";
import { useGraphStore } from "../store/useGraphStore";
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
interface ForceGraphProps {
data: GraphData;
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
}
export const ForceGraph = forwardRef<any, ForceGraphProps>(
({ data, onNodeRightClick }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
const highlightNodes = useGraphStore((s) => s.highlightNodes);
const highlightLinks = useGraphStore((s) => s.highlightLinks);
const selectedNode = useGraphStore((s) => s.selectedNode);
const isLinkMode = useGraphStore((s) => s.isLinkMode);
const theme = useThemeStore((s) => s.theme);
const isDark = theme === "dark";
// Определяем цвета текста в зависимости от темы
const nodeTextColor = isDark ? "#e5e7eb" : "#1f2937";
const nodeTextLetterColor = isDark ? "#ffffff" : "#000000";
// ResizeObserver для корректного отслеживания размеров
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateDimensions = () => {
setDimensions({
width: container.clientWidth,
height: container.clientHeight,
});
};
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
observer.observe(container);
return () => observer.disconnect();
}, []);
const handleNodeClick = useCallback((node: GraphNode) => {
const store = useGraphStore.getState();
if (store.isLinkMode) {
if (store.selectedNode === null) {
store.setSelectedNode(node);
} else if (store.selectedNode.id !== node.id) {
store.createLink(store.selectedNode.id, node.id);
store.setSelectedNode(null);
store.toggleLinkMode();
} else {
store.setSelectedNode(null);
}
}
}, []);
const handleNodeHover = (node: GraphNode | null) => {
const newHighlightNodes = new Set<string>();
const newHighlightLinks = new Set<GraphLink>();
if (node) {
newHighlightNodes.add(node.id);
data.links.forEach((link) => {
if (link.source === node.id || link.target === node.id) {
newHighlightLinks.add(link);
newHighlightNodes.add(link.source as string);
newHighlightNodes.add(link.target as string);
}
});
}
useGraphStore
.getState()
.setHighlight(newHighlightNodes, newHighlightLinks);
};
const getNodeColor = (node: GraphNode) => {
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
if (node.type === "service" && node.status === "down") {
// Проверяем, есть ли зависимости этого сервиса, которые тоже упали
const hasDownDependency = data.links.some((link) => {
const sourceId =
typeof link.source === "object"
? (link.source as any).id
: link.source;
const targetId =
typeof link.target === "object"
? (link.target as any).id
: link.target;
if (sourceId !== node.id) return false;
const isDependency =
link.type === "dependency" || link.type === "started";
const targetIsDown = data.nodes.some(
(n) => n.id === targetId && n.status === "down",
);
return isDependency && targetIsDown;
});
// Если есть упавшая зависимость — не подсвечиваем красным
if (hasDownDependency) return "#3b82f6";
return "#ef4444";
}
if (node.type === "agent") {
// Проверяем, есть ли у агента хотя бы один упавший сервис
const hasDownService = data.nodes.some(
(n) =>
n.type === "service" &&
n.status === "down" &&
n.id.startsWith(`${node.id}-`),
);
if (hasDownService) return "#ef4444";
}
switch (node.type) {
case "service":
return "#3b82f6";
case "agent":
return "#8b5cf6";
default:
return "#6b7280";
}
};
const getNodeSize = (node: GraphNode) => {
switch (node.type) {
case "service":
return 3;
case "agent":
return 3;
default:
return 5;
}
};
const renderNode = (
node: GraphNode,
ctx: CanvasRenderingContext2D,
globalScale: number,
) => {
const size = getNodeSize(node);
const color = getNodeColor(node);
if (!node.x || !node.y) return;
ctx.beginPath();
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.fillStyle = nodeTextLetterColor;
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (node.type === "service") {
ctx.fillText("S", node.x, node.y);
} else if (node.type === "agent") {
ctx.fillText("A", node.x, node.y);
}
if (globalScale > 0.5) {
ctx.fillStyle = nodeTextColor;
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
ctx.textAlign = "center";
ctx.fillText(node.name, node.x, node.y + size + 8);
}
};
const handleEngineStop = () => {
if (typeof ref !== "function" && ref && "current" in ref && ref.current) {
ref.current.zoomToFit(400);
}
};
return (
<div ref={containerRef} className="w-full h-full relative">
<ForceGraph2D
ref={ref}
graphData={data}
width={dimensions.width}
height={dimensions.height}
nodeCanvasObject={renderNode}
nodeLabel={(node: GraphNode) => {
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
}}
linkLabel={(link: GraphLink) => {
const sourceName =
data.nodes.find((n) => n.id === link.source)?.name || link.source;
const targetName =
data.nodes.find((n) => n.id === link.target)?.name || link.target;
return `Связь: ${sourceName}${targetName}\nПКМ для удаления`;
}}
linkColor={(link: any) => {
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
}}
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
linkDirectionalParticles={0}
onNodeClick={handleNodeClick}
onNodeRightClick={onNodeRightClick}
onNodeHover={handleNodeHover}
cooldownTicks={50}
cooldownTime={2000}
d3AlphaDecay={0.03}
d3VelocityDecay={0.4}
warmupTicks={50}
onEngineStop={handleEngineStop}
/>
</div>
);
},
);
ForceGraph.displayName = "ForceGraph";
@@ -0,0 +1,86 @@
import React from "react";
import { FiLink, FiTrash2 } from "react-icons/fi";
import type { ContextMenuState, GraphNode, GraphData } from "../types";
import { useGraphStore } from "../store/useGraphStore";
interface GraphContextMenuProps {
menu: ContextMenuState | null;
data: GraphData;
onClose: () => void;
}
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
menu,
data,
onClose,
}) => {
const removeNode = useGraphStore((s) => s.removeNode);
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
if (!menu) return null;
const handleDeleteNode = (node: GraphNode) => {
removeNode(node.id);
onClose();
};
const handleCreateLink = (node: GraphNode) => {
toggleLinkMode();
setSelectedNode(node);
onClose();
};
return (
<div
className="fixed rounded-lg shadow-lg py-1 z-50"
style={{
top: menu.y,
left: menu.x,
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
}}
onClick={(e) => e.stopPropagation()}
>
{menu.node && (
<>
<div
className="px-3 py-1 text-xs border-b"
style={{
color: "var(--text-secondary)",
borderColor: "var(--border)",
}}
>
{menu.node.name}
</div>
<button
onClick={() => handleCreateLink(menu.node!)}
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "var(--bg-secondary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "transparent")
}
>
<FiLink size={14} /> Создать связь
</button>
<button
onClick={() => handleDeleteNode(menu.node!)}
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
style={{ color: "#f87171" }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(248,113,113,0.1)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "transparent")
}
>
<FiTrash2 size={14} /> Удалить узел
</button>
</>
)}
</div>
);
};
@@ -0,0 +1,105 @@
import React from "react";
import {
FiDownload,
FiZoomIn,
FiZoomOut,
FiMove,
FiLink,
} from "react-icons/fi";
import { useGraphStore } from "../store/useGraphStore";
import type { GraphData } from "../types";
interface GraphControlsProps {
fgRef: React.RefObject<any>;
onExport?: () => void;
onDataChange?: (data: GraphData) => void;
}
const btnStyle: React.CSSProperties = {
backgroundColor: "var(--bg-secondary)",
color: "var(--text-primary)",
};
export const GraphControls: React.FC<GraphControlsProps> = ({
fgRef,
onExport,
onDataChange,
}) => {
const isLinkMode = useGraphStore((s) => s.isLinkMode);
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
const exportData = useGraphStore((s) => s.exportData);
const handleZoomIn = () => {
if (fgRef.current) {
const currentZoom = fgRef.current.zoom();
fgRef.current.zoom(currentZoom * 1.2);
}
};
const handleZoomOut = () => {
if (fgRef.current) {
const currentZoom = fgRef.current.zoom();
fgRef.current.zoom(currentZoom / 1.2);
}
};
const handleFit = () => {
if (fgRef.current) {
fgRef.current.zoomToFit(400);
}
};
return (
<div className="flex items-center justify-end gap-2 mt-2">
{/* Режим создания связи */}
{/* <button
onClick={toggleLinkMode}
className="flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
style={{
backgroundColor: isLinkMode ? "#22c55e" : "var(--bg-secondary)",
color: isLinkMode ? "#fff" : "var(--text-primary)",
}}
>
<FiLink />
<span>{isLinkMode ? "Создание связи..." : "Добавить связь"}</span>
</button> */}
{/* Зум + */}
<button
onClick={handleZoomIn}
className="p-2 rounded-lg transition-colors"
style={btnStyle}
>
<FiZoomIn />
</button>
{/* Зум - */}
<button
onClick={handleZoomOut}
className="p-2 rounded-lg transition-colors"
style={btnStyle}
>
<FiZoomOut />
</button>
{/* Fit */}
<button
onClick={handleFit}
className="p-2 rounded-lg transition-colors"
style={btnStyle}
>
<FiMove />
</button>
{/* Экспорт */}
<button
onClick={onExport || exportData}
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
style={btnStyle}
>
<FiDownload />
<span>Экспорт</span>
</button>
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import type { GraphData } from "../types";
interface GraphStatsProps {
data: GraphData;
}
export const GraphStats: React.FC<GraphStatsProps> = ({ data }) => {
return (
<div
className="flex gap-4 text-xs"
style={{ color: "var(--text-secondary)" }}
>
<span>
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
</span>
<span>Агенты: {data.nodes.filter((n) => n.type === "agent").length}</span>
<div className="flex items-center gap-1.5">
<div
className="w-2 h-2 rounded-sm"
style={{ backgroundColor: "var(--text-muted)" }}
></div>
<span>Связи: {data.links.length}</span>
</div>
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import { FiLink } from "react-icons/fi";
import type { GraphNode } from "../types";
interface GraphStatusBarProps {
isLinkMode: boolean;
selectedNode: GraphNode | null;
}
export const GraphStatusBar: React.FC<GraphStatusBarProps> = ({
isLinkMode,
selectedNode,
}) => {
if (!isLinkMode) return null;
return (
<div
className="absolute bottom-4 left-4 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2"
style={{ backgroundColor: "#22c55e" }}
>
<FiLink /> Режим создания связей: кликните на два узла для соединения
{selectedNode && (
<span className="ml-2">Выбран: {selectedNode.name}</span>
)}
</div>
);
};
@@ -0,0 +1,5 @@
export { ForceGraph } from "./ForceGraph";
export { GraphControls } from "./GraphControls";
export { GraphContextMenu } from "./GraphContextMenu";
export { GraphStatusBar } from "./GraphStatusBar";
export { GraphStats } from "./GraphStats";
+3
View File
@@ -0,0 +1,3 @@
export { Graph } from "./Graph";
export { useGraphStore } from "./store/useGraphStore";
export type { GraphData, GraphNode, GraphLink } from "./types";
@@ -0,0 +1,113 @@
import { create } from "zustand";
import type { GraphData, GraphNode, GraphLink } from "../types";
interface GraphState {
data: GraphData;
highlightNodes: Set<string>;
highlightLinks: Set<GraphLink>;
isLinkMode: boolean;
selectedNode: GraphNode | null;
// Действия с данными
setData: (data: GraphData) => void;
addNode: (node: GraphNode) => void;
removeNode: (nodeId: string) => void;
addLink: (link: GraphLink) => void;
removeLink: (link: GraphLink) => void;
// Подсветка
setHighlight: (nodeIds: Set<string>, links: Set<GraphLink>) => void;
// Режим связи
toggleLinkMode: () => void;
setSelectedNode: (node: GraphNode | null) => void;
createLink: (sourceId: string, targetId: string) => void;
// Экспорт
exportData: () => void;
}
export const useGraphStore = create<GraphState>((set, get) => ({
data: { nodes: [], links: [] },
highlightNodes: new Set(),
highlightLinks: new Set(),
isLinkMode: false,
selectedNode: null,
setData: (data) => set({ data }),
addNode: (node) => {
set((state) => ({
data: {
...state.data,
nodes: [...state.data.nodes, node],
},
}));
},
removeNode: (nodeId) => {
set((state) => ({
data: {
nodes: state.data.nodes.filter((n) => n.id !== nodeId),
links: state.data.links.filter(
(l) => l.source !== nodeId && l.target !== nodeId,
),
},
}));
},
addLink: (link) => {
set((state) => ({
data: {
...state.data,
links: [...state.data.links, link],
},
}));
},
removeLink: (linkToRemove) => {
set((state) => ({
data: {
...state.data,
links: state.data.links.filter((l) => l !== linkToRemove),
},
}));
},
setHighlight: (nodeIds, links) =>
set({ highlightNodes: nodeIds, highlightLinks: links }),
toggleLinkMode: () =>
set((state) => ({
isLinkMode: !state.isLinkMode,
selectedNode: null,
})),
setSelectedNode: (node) => set({ selectedNode: node }),
createLink: (sourceId, targetId) => {
const { data, addLink } = get();
const linkExists = data.links.some(
(link) =>
(link.source === sourceId && link.target === targetId) ||
(link.source === targetId && link.target === sourceId),
);
if (!linkExists) {
addLink({ source: sourceId, target: targetId, type: "custom" });
}
},
exportData: () => {
const { data } = get();
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "graph-data.json";
link.click();
URL.revokeObjectURL(url);
},
}));
+50
View File
@@ -0,0 +1,50 @@
export interface GraphNode {
id: string;
name: string;
type: "agent" | "service";
val?: number;
description?: string;
x?: number;
y?: number;
status?: "up" | "down";
}
export interface GraphLink {
source: string;
target: string;
type?: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface ContextMenuState {
x: number;
y: number;
node: GraphNode | null;
link: GraphLink | null;
}
// API response types for GET /graph
export interface GraphDependencyTarget {
name: string;
}
export interface GraphDependency {
condition: string;
target: GraphDependencyTarget;
}
export interface GraphServiceNode {
dependencies: GraphDependency[];
}
export interface GraphAgentNode {
services: Record<string, GraphServiceNode>;
}
export interface GraphApiResponse {
nodes: Record<string, GraphAgentNode>;
}
+338
View File
@@ -0,0 +1,338 @@
import React, { useEffect } from "react";
import { MdAdd, MdArrowBack } from "react-icons/md";
import { GoTrash } from "react-icons/go";
import {
useIDEStore,
initialFiles as defaultInitialFiles,
} from "./store/useIDEStore";
import type { FileNode } from "./types";
import {
FileExplorer,
TabBar,
CodeEditor,
TitleBar,
StatusBar,
} from "./components";
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
interface IDEProps {
initialFiles?: FileNode;
onBack?: () => void;
}
const darkColors = {
bg: "#1e1e1e",
bgSecondary: "#252526",
bgTertiary: "#2d2d30",
border: "#3e3e42",
textPrimary: "#cccccc",
textSecondary: "#858585",
accent: "#0e639c",
accentHover: "#1177bb",
statusBar: "#007acc",
};
const lightColors = {
bg: "#ffffff",
bgSecondary: "#f3f3f3",
bgTertiary: "#e8e8e8",
border: "#e0e0e0",
textPrimary: "#333333",
textSecondary: "#616161",
accent: "#0e639c",
accentHover: "#1177bb",
statusBar: "#007acc",
};
export const IDE: React.FC<IDEProps> = ({
initialFiles: externalFiles,
onBack,
}: IDEProps = {}) => {
const theme = useThemeStore((s) => s.theme);
const isDark = theme === "dark";
const c = isDark ? darkColors : lightColors;
const files = useIDEStore((state) => state.files);
const openFiles = useIDEStore((state) => state.openFiles);
const activeFile = useIDEStore((state) => state.activeFile);
const createNewProject = useIDEStore((state) => state.createNewProject);
const selectFile = useIDEStore((state) => state.selectFile);
const updateFileContent = useIDEStore((state) => state.updateFileContent);
const saveActiveFile = useIDEStore((state) => state.saveActiveFile);
const closeFile = useIDEStore((state) => state.closeFile);
const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
const initialize = useIDEStore((state) => state.initialize);
const isInitialized = useIDEStore((state) => state.isInitialized);
const fetchTree = useIDEStore((state) => state.fetchTree);
const fetchInterpreters = useIDEStore((state) => state.fetchInterpreters);
// Загружаем интерпретаторы при инициализации
useEffect(() => {
fetchInterpreters();
}, []);
// Обработка Ctrl+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveActiveFile();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [saveActiveFile]);
// При загрузке пробуем загрузить дерево с сервера
useEffect(() => {
if (!isInitialized) {
fetchTree().catch(() => {
// Только при ошибке — используем моковые данные
const state = useIDEStore.getState();
if (!state.files) {
const filesToInit = externalFiles || defaultInitialFiles;
initialize(filesToInit);
}
});
}
}, [isInitialized]);
// Если проект не открыт
if (!files) {
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
backgroundColor: c.bg,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
position: "relative",
}}
>
<TitleBar />
{onBack && (
<button
onClick={onBack}
style={{
position: "absolute",
top: "40px",
left: "12px",
background: "transparent",
border: `1px solid ${c.border}`,
color: c.textPrimary,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "6px",
fontSize: "12px",
transition: "all 0.1s",
zIndex: 10,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = c.border;
e.currentTarget.style.color = "#fff";
e.currentTarget.style.borderColor = "#555";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = c.textPrimary;
e.currentTarget.style.borderColor = c.border;
}}
title="Go back"
>
<MdArrowBack size={16} />
<span>Back</span>
</button>
)}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ textAlign: "center" }}>
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "center",
opacity: 0.3,
}}
>
<GoTrash size={72} />
</div>
<div
style={{
fontSize: "22px",
marginBottom: "12px",
color: c.textPrimary,
fontWeight: 300,
}}
>
No project open
</div>
<div
style={{
fontSize: "13px",
marginBottom: "32px",
color: c.textSecondary,
}}
>
Create a new project to get started
</div>
<button
onClick={createNewProject}
style={{
padding: "10px 24px",
backgroundColor: c.accent,
border: "none",
borderRadius: "4px",
color: "#fff",
cursor: "pointer",
fontSize: "13px",
fontWeight: 500,
transition: "background-color 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = c.accentHover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = c.accent;
}}
>
<MdAdd size={14} /> New Project
</button>
</div>
</div>
<StatusBar activeFile={null} />
</div>
);
}
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
backgroundColor: c.bg,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}}
>
<div
style={{
height: "30px",
backgroundColor: c.bgTertiary,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 8px",
borderBottom: `1px solid ${c.bg}`,
fontSize: "12px",
color: c.textPrimary,
userSelect: "none",
flexShrink: 0,
}}
>
{onBack && (
<button
onClick={onBack}
style={{
background: "transparent",
border: "none",
color: c.textPrimary,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "11px",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = c.border;
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = c.textPrimary;
}}
title="Go back"
>
<MdArrowBack size={14} />
<span>Back</span>
</button>
)}
{!onBack && <div />}
<span style={{ fontWeight: 400 }}>
{activeFile
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
: ""}
{files.name}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{activeFile?.dirty && (
<button
onClick={saveActiveFile}
style={{
background: "transparent",
border: "none",
color: c.textPrimary,
cursor: "pointer",
fontSize: "11px",
padding: "4px 8px",
borderRadius: "4px",
}}
title="Сохранить (Ctrl+S)"
>
Сохранить
</button>
)}
</div>
</div>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<div style={{ width: "260px", flexShrink: 0 }}>
<FileExplorer
files={files}
onDeleteRoot={useIDEStore.getState().deleteRoot}
/>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<TabBar
openFiles={openFiles}
activeFile={activeFile}
onSelectFile={selectFile}
onCloseFile={closeFile}
onCloseAll={closeAllFiles}
onCloseOthers={closeOtherFiles}
/>
<CodeEditor
filePath={activeFile?.path || ""}
content={activeFile?.content || ""}
onChange={updateFileContent}
/>
</div>
</div>
<StatusBar activeFile={activeFile} />
</div>
);
};
export default IDE;
+138
View File
@@ -0,0 +1,138 @@
import { apiClient } from "@/shared/api/axios.instance";
import type { Interpreter } from "../types";
export interface ScriptNodeDto {
id: number;
name: string;
type: "file" | "folder";
content?: string;
children?: string[];
interpreter_id?: number;
}
export interface ScriptResponse {
id: number;
content: string;
interpreter_id: number;
path: string;
created_at: string;
updated_at: string;
}
export interface CreateScriptPayload {
content: string;
interpreter_id: number;
path: string;
}
export interface UpdateScriptPayload {
content: string;
interpreter_id: number;
path: string;
}
export interface RunScriptPayload {
stdin?: string;
token: string;
}
export interface RunScriptResponse {
command: string[];
id: number;
wait_url: string;
}
export interface CreateInterpreterPayload {
argv: string[];
label: string;
name: string;
}
export interface JobWaitResponse {
command: string[];
id: number;
status: number;
stderr: string;
stdin: string;
stdout: string;
}
// apiClient уже имеет интерсептор для Authorization header
export const scriptsApi = {
getInterpreters: async (): Promise<Interpreter[]> => {
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
return res.data;
},
getTree: async (): Promise<ScriptNodeDto[]> => {
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
return res.data;
},
createScript: async (
payload: CreateScriptPayload,
): Promise<ScriptResponse> => {
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
return res.data;
},
updateScript: async (
id: number,
payload: UpdateScriptPayload,
): Promise<ScriptResponse> => {
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
return res.data;
},
deleteScript: async (id: number): Promise<void> => {
await apiClient.delete(`/scripts/${id}`);
},
createFolder: async (path: string): Promise<{ path: string }> => {
const res = await apiClient.post<{ path: string }>("/scripts/folder", {
path,
});
return res.data;
},
deleteFolder: async (path: string): Promise<void> => {
await apiClient.delete(`/scripts/folder`, { data: { path } });
},
rename: async (payload: {
old_path: string;
new_path: string;
}): Promise<{ path: string }> => {
const res = await apiClient.post<{ path: string }>(
"/scripts/rename",
payload,
);
return res.data;
},
runScript: async (
id: number,
payload: RunScriptPayload,
): Promise<RunScriptResponse> => {
const res = await apiClient.post<RunScriptResponse>(
`/scripts/${id}/run`,
payload,
);
return res.data;
},
waitJob: async (id: number): Promise<JobWaitResponse> => {
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
return res.data;
},
createInterpreter: async (
payload: CreateInterpreterPayload,
): Promise<Interpreter> => {
const res = await apiClient.post<Interpreter>(
"/scripts/interpreters",
payload,
);
return res.data;
},
};
@@ -0,0 +1,276 @@
import React, { useState, useRef } from "react";
import { MdClose, MdAdd } from "react-icons/md";
import { scriptsApi } from "../api/scripts.api";
import type { CreateInterpreterPayload } from "../api/scripts.api";
interface AddInterpreterModalProps {
onClose: () => void;
onSuccess: () => void;
}
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
onClose,
onSuccess,
}) => {
const [name, setName] = useState("");
const [label, setLabel] = useState("");
const [argv, setArgv] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
React.useEffect(() => {
nameRef.current?.focus();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !label.trim()) {
setError("Name and Label are required");
return;
}
setLoading(true);
setError(null);
try {
const payload: CreateInterpreterPayload = {
name: name.trim(),
label: label.trim(),
argv: argv
.split(" ")
.map((s) => s.trim())
.filter(Boolean),
};
await scriptsApi.createInterpreter(payload);
onSuccess();
onClose();
} catch (e: any) {
console.error("Failed to create interpreter:", e);
setError(e?.response?.data?.detail || "Failed to create interpreter");
} finally {
setLoading(false);
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
width: "420px",
maxWidth: "90vw",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
}}
>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: "14px",
fontWeight: 600,
}}
>
Add Interpreter
</h3>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "var(--text-secondary)",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
}}
>
<MdClose size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
{/* Name */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Name <span style={{ color: "#f44747" }}>*</span>
</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Python, Node.js, etc."
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Label */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Label <span style={{ color: "#f44747" }}>*</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="python3, node, etc."
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Args */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Arguments <span style={{ color: "#858585" }}>(optional)</span>
</label>
<input
type="text"
value={argv}
onChange={(e) => setArgv(e.target.value)}
placeholder="-u -O (space separated)"
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Error */}
{error && (
<div
style={{
padding: "8px 12px",
backgroundColor: "rgba(244, 71, 71, 0.1)",
border: "1px solid #f44747",
borderRadius: "4px",
color: "#f44747",
fontSize: "12px",
marginBottom: "16px",
}}
>
{error}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "10px",
backgroundColor: loading ? "#555" : "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
fontSize: "13px",
fontWeight: 500,
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
}}
>
{loading ? (
<>
<span
style={{
display: "inline-block",
animation: "spin 1s linear infinite",
}}
>
</span>
Creating...
</>
) : (
<>
<MdAdd size={16} />
Add Interpreter
</>
)}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import React from "react";
import Editor from "@monaco-editor/react";
import { FiFolder } from "react-icons/fi";
import { getLanguage } from "../helpers/fileTree";
interface CodeEditorProps {
filePath: string;
content: string;
onChange: (content: string) => void;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
filePath,
content,
onChange,
}) => {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#1e1e1e",
}}
>
<div style={{ flex: 1 }}>
{filePath ? (
<Editor
height="100%"
language={getLanguage(filePath)}
value={content}
onChange={(value) => onChange(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
tabSize: 4,
wordWrap: "on",
lineNumbers: "on",
automaticLayout: true,
renderWhitespace: "selection",
smoothScrolling: true,
}}
/>
) : (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "#858585",
textAlign: "center",
}}
>
<div>
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "center",
opacity: 0.5,
}}
>
<FiFolder size={64} />
</div>
<div
style={{
fontSize: "18px",
marginBottom: "12px",
color: "#cccccc",
}}
>
Welcome to Web VS Code
</div>
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
Right-click on a folder to create files
</div>
<div style={{ fontSize: "12px", color: "#0e639c" }}>
Or right-click anywhere in the explorer
</div>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,99 @@
import React, { useEffect } from "react";
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
const MenuItem: React.FC<{
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}> = ({ onClick, danger, children }) => (
<div
onClick={onClick}
style={{
padding: "8px 16px",
cursor: "pointer",
color: danger ? "#f48771" : "#cccccc",
fontSize: "13px",
transition: "background-color 0.1s",
display: "flex",
alignItems: "center",
gap: "10px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{children}
</div>
);
interface ContextMenuProps {
x: number;
y: number;
onClose: () => void;
onNewFile: () => void;
onNewFolder: () => void;
onRename: () => void;
onDelete: () => void;
hasNode: boolean;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
onClose,
onNewFile,
onNewFolder,
onRename,
onDelete,
hasNode,
}) => {
useEffect(() => {
const handleClick = () => onClose();
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [onClose]);
return (
<div
style={{
position: "fixed",
top: y,
left: x,
backgroundColor: "#252526",
border: "1px solid #3e3e42",
borderRadius: "6px",
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
zIndex: 1000,
minWidth: "180px",
overflow: "hidden",
}}
>
<MenuItem onClick={onNewFile}>
<FiFile /> New File
</MenuItem>
<MenuItem onClick={onNewFolder}>
<FiFolder /> New Folder
</MenuItem>
{hasNode && (
<>
<div
style={{
height: "1px",
backgroundColor: "#3e3e42",
margin: "4px 0",
}}
/>
<MenuItem onClick={onRename}>
<FiEdit3 /> Rename
</MenuItem>
<MenuItem onClick={onDelete} danger>
<FiTrash2 /> Delete
</MenuItem>
</>
)}
</div>
);
};
@@ -0,0 +1,345 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
import { GoKebabHorizontal } from "react-icons/go";
import { MdClose, MdAdd } from "react-icons/md";
import { FileTreeItem } from "./FileTreeItem";
import { ContextMenu } from "./ContextMenu";
import { InputDialog } from "./InputDialog";
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
import { useIDEStore } from "../store/useIDEStore";
import type { FileNode } from "../types";
interface FileExplorerProps {
files: FileNode;
onDeleteRoot: () => void;
}
export const FileExplorer: React.FC<FileExplorerProps> = ({
files,
onDeleteRoot,
}) => {
const store = useIDEStore();
const [showSearch, setShowSearch] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Фокус на инпут при открытии поиска
useEffect(() => {
if (showSearch) {
searchInputRef.current?.focus();
}
}, [showSearch]);
const handleSearchBlur = useCallback(() => {
// Скрываем поиск при потере фокуса с небольшой задержкой,
// чтобы клики по кнопке очистки успели сработать
setTimeout(() => {
if (
searchInputRef.current &&
!searchInputRef.current.contains(document.activeElement)
) {
setShowSearch(false);
store.setSearchQuery("");
}
}, 100);
}, [store]);
const handleEmptyContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Загружаем интерпретаторы перед открытием меню
if (store.interpreters.length === 0) {
store.fetchInterpreters();
}
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
};
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
e.preventDefault();
e.stopPropagation();
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
};
// Загружаем интерпретаторы при монтировании компонента
useEffect(() => {
if (store.interpreters.length === 0) {
store.fetchInterpreters();
}
}, []);
const filteredFiles = store.searchQuery
? (files.children || [])
.map((child) => filterTree(child, store.searchQuery))
.filter((child): child is FileNode => child !== null)
: files.children || [];
useEffect(() => {
if (store.searchQuery && files) {
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
if (pathsToExpand.size > 0) {
store.autoExpandPaths(pathsToExpand);
}
}
}, [store.searchQuery, files, store.autoExpandPaths]);
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#252526",
}}
onContextMenu={handleEmptyContextMenu}
>
<div
style={{
padding: "0 8px",
height: "35px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "1px solid #3e3e42",
}}
>
<span
style={{
color: "#bbbbbb",
fontWeight: 500,
fontSize: "11px",
letterSpacing: "0.8px",
}}
>
EXPLORER
</span>
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
<button
onClick={() => {
if (!showSearch) {
setShowSearch(true);
} else {
setShowSearch(false);
store.setSearchQuery("");
}
}}
style={{
background: "transparent",
border: "none",
color: showSearch ? "#cccccc" : "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
title="Search in files"
>
<FiSearch size={14} />
</button>
<button
onClick={store.collapseAllFolders}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
e.currentTarget.style.color = "#cccccc";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#858585";
}}
title="Collapse All"
>
<FiMinus size={14} />
</button>
<button
onClick={store.expandAllFolders}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
e.currentTarget.style.color = "#cccccc";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#858585";
}}
title="Expand All"
>
<GoKebabHorizontal size={14} />
</button>
</div>
</div>
{showSearch && (
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "#3c3c3c",
border: store.searchQuery
? "1px solid #007acc"
: "1px solid transparent",
borderRadius: "4px",
padding: "0 6px",
transition: "border-color 0.1s",
}}
>
<FiSearch size={13} color="#858585" />
<input
ref={searchInputRef}
type="text"
value={store.searchQuery}
onChange={(e) => store.setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Search..."
style={{
flex: 1,
padding: "5px 6px",
backgroundColor: "transparent",
border: "none",
color: "#cccccc",
fontSize: "12px",
outline: "none",
}}
/>
{store.searchQuery && (
<button
onClick={() => store.setSearchQuery("")}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
}}
>
<MdClose size={12} />
</button>
)}
</div>
</div>
)}
<div style={{ flex: 1, overflowY: "auto" }}>
{filteredFiles.length > 0 ? (
filteredFiles.map((child, idx) => (
<FileTreeItem
key={idx}
node={child}
level={0}
onFileSelect={store.selectFile}
selectedFile={store.activeFile?.path || null}
onContextMenu={handleNodeContextMenu}
expandedFolders={store.expandedFolders}
onToggleFolder={store.toggleFolder}
onDelete={store.handleDeleteNode}
searchQuery={store.searchQuery}
/>
))
) : (
<div
style={{
padding: "16px",
color: "#858585",
fontSize: "13px",
textAlign: "center",
}}
>
No results found
</div>
)}
</div>
{store.contextMenu && (
<ContextMenu
x={store.contextMenu.x}
y={store.contextMenu.y}
onClose={() => store.setContextMenu(null)}
onNewFile={() => {
store.setDialog({
type: "newFile",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onNewFolder={() => {
store.setDialog({
type: "newFolder",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onRename={() => {
store.setDialog({
type: "rename",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onDelete={() => {
if (store.contextMenu?.node) {
store.handleDeleteNode(store.contextMenu.node);
}
store.setContextMenu(null);
}}
hasNode={!!store.contextMenu.node}
/>
)}
{store.dialog && (
<InputDialog
title={
store.dialog.type === "newFile"
? "New File"
: store.dialog.type === "newFolder"
? "New Folder"
: "Rename"
}
initialValue={
store.dialog.type === "rename" && store.dialog.node
? store.dialog.node.name
: ""
}
onConfirm={(value, interpreterId) => {
store.handleDialogConfirm(value, interpreterId);
}}
onCancel={() => store.setDialog(null)}
interpreters={
store.dialog.type === "newFile" ? store.interpreters : undefined
}
/>
)}
</div>
);
};
@@ -0,0 +1,83 @@
import React from "react";
import type { FileNode } from "../types";
import { FilePickerItem } from "./FilePickerItem";
import { useFilePickerStore } from "../store/useFilePickerStore";
import { TerminalOutput } from "@/modules/terminal";
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
interface FilePickerProps {
files: FileNode;
onRun?: (path: string) => void;
}
const FilePickerTree: React.FC<{
node: FileNode;
level: number;
onRun?: (path: string) => void;
}> = ({ node, level, onRun }) => {
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
const nodePath = node.path || node.name;
const isExpanded = expandedFolders.has(nodePath);
if (node.type === "file") {
return (
<FilePickerItem
name={node.name}
type="file"
path={nodePath}
level={level}
onRun={onRun}
/>
);
}
return (
<>
<FilePickerItem
name={node.name}
type="folder"
path={nodePath}
isExpanded={isExpanded}
level={level}
onToggleFolder={toggleFolder}
>
{node.children?.map((child, idx) => (
<FilePickerTree
key={idx}
node={child}
level={level + 1}
onRun={onRun}
/>
))}
</FilePickerItem>
</>
);
};
export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
const terminalOpen = useTerminalStore((s) => s.isOpen);
const jobs = useTerminalStore((s) => s.jobs);
return (
<div
style={{
height: "100%",
overflowY: "auto",
backgroundColor: "var(--bg-primary)",
}}
>
{/* Terminal — сверху, над списком файлов */}
{terminalOpen && jobs.length > 0 && (
<div style={{ height: 250 }}>
<TerminalOutput />
</div>
)}
{(files.children || []).map((child, idx) => (
<FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
))}
</div>
);
};
@@ -0,0 +1,165 @@
import React from "react";
import {
FiChevronRight,
FiChevronDown,
FiFile,
FiFolder,
FiPlay,
} from "react-icons/fi";
interface FilePickerItemProps {
name: string;
type: "file" | "folder";
path: string;
isExpanded?: boolean;
children?: React.ReactNode;
level: number;
onToggleSelect?: (path: string) => void;
onToggleFolder?: (path: string) => void;
onRun?: (path: string) => void;
}
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
name,
type,
path,
isExpanded,
children,
level,
onToggleSelect,
onToggleFolder,
onRun,
}) => {
const isFolder = type === "folder";
const extension = name.includes(".")
? name.split(".").pop()?.toUpperCase()
: "";
const paddingLeft = 12 + level * 20;
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: `${paddingLeft}px`,
paddingRight: "12px",
height: "36px",
borderBottom: "1px solid var(--border)",
cursor: "pointer",
transition: "background-color 0.1s",
gap: "8px",
}}
onClick={() => {
if (isFolder && onToggleFolder) {
onToggleFolder(path);
} else if (!isFolder && onToggleSelect) {
onToggleSelect(path);
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{/* Folder expand icon */}
{isFolder && (
<span
style={{
color: "var(--text-secondary)",
display: "flex",
flexShrink: 0,
}}
>
{isExpanded ? (
<FiChevronDown size={14} />
) : (
<FiChevronRight size={14} />
)}
</span>
)}
{/* File/Folder icon */}
<span style={{ display: "flex", flexShrink: 0 }}>
{isFolder ? (
<FiFolder size={15} color="var(--accent)" />
) : (
<FiFile size={15} color="var(--text-secondary)" />
)}
</span>
{/* Name */}
<span
style={{
flex: 1,
color: "var(--text-primary)",
fontSize: "13px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
</span>
{/* Extension badge — только у файлов */}
{!isFolder && extension && (
<span
style={{
color: "var(--text-secondary)",
fontSize: "11px",
fontFamily: "monospace",
padding: "2px 6px",
backgroundColor: "var(--bg-secondary)",
borderRadius: "3px",
flexShrink: 0,
}}
>
{extension}
</span>
)}
{/* Run button — только у файлов */}
{!isFolder && onRun && (
<button
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px",
backgroundColor: "transparent",
border: "1px solid transparent",
borderRadius: "3px",
color: "var(--text-secondary)",
cursor: "pointer",
flexShrink: 0,
transition: "all 0.15s",
}}
onClick={(e) => {
e.stopPropagation();
onRun(path);
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#238636";
e.currentTarget.style.color = "#ffffff";
e.currentTarget.style.borderColor = "#2ea043";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
e.currentTarget.style.borderColor = "transparent";
}}
title="Run script"
>
<FiPlay size={12} />
</button>
)}
</div>
{/* Children */}
{isFolder && isExpanded && children}
</div>
);
};
@@ -0,0 +1,169 @@
import React, { useState } from "react";
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
import { GoFile } from "react-icons/go";
import type { FileNode } from "../types";
interface FileTreeItemProps {
node: FileNode;
level: number;
onFileSelect: (node: FileNode) => void;
selectedFile: string | null;
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
onDelete: (node: FileNode) => void;
isRoot?: boolean;
searchQuery?: string;
}
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
node,
level,
onFileSelect,
selectedFile,
onContextMenu,
expandedFolders,
onToggleFolder,
onDelete,
isRoot,
searchQuery,
}) => {
const isFolder = node.type === "folder";
const isSelected = selectedFile === node.path && !isFolder;
const isExpanded = expandedFolders.has(node.path || node.name);
const [hovered, setHovered] = useState(false);
const handleClick = () => {
if (isFolder) {
onToggleFolder(node.path || node.name);
} else {
onFileSelect(node);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete(node);
};
const highlightText = (text: string, query: string) => {
if (!query) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
{text.slice(idx, idx + query.length)}
</span>
{text.slice(idx + query.length)}
</>
);
};
return (
<div>
<div
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, node)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
paddingTop: "4px",
paddingBottom: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
backgroundColor: isSelected ? "#094771" : "transparent",
color: isSelected ? "#fff" : "#cccccc",
fontSize: "13px",
transition: "background-color 0.1s",
userSelect: "none",
minHeight: "28px",
}}
>
<span
style={{
fontSize: "14px",
width: "16px",
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{isFolder ? (
isExpanded ? (
<FiChevronDown />
) : (
<FiChevronRight />
)
) : (
<GoFile />
)}
</span>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
</span>
{hovered && !isRoot && (
<button
onClick={handleDelete}
title={`Delete ${node.name}`}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "3px",
flexShrink: 0,
width: "20px",
height: "20px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#f48771";
e.currentTarget.style.backgroundColor = "#3e3e42";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "#858585";
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FiTrash2 size={13} />
</button>
)}
</div>
{isFolder && isExpanded && node.children && (
<div>
{node.children.map((child, idx) => (
<FileTreeItem
key={idx}
node={child}
level={level + 1}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
onContextMenu={onContextMenu}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
onDelete={onDelete}
searchQuery={searchQuery}
/>
))}
</div>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More