104 Commits

Author SHA1 Message Date
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
d3m0k1d b516a54c17 fixsess and logic for web ide
ci-agent / build (push) Failing after 2m42s
2026-04-04 23:56:28 +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
d3m0k1d 1e4e65bb84 fix: agent init
ci-agent / build (push) Failing after 2m41s
2026-04-04 21:22:37 +03:00
zero@thinky 3389df740c feat!(proto): change service monitor from stream to unary
ci-agent / build (push) Failing after 2m34s
2026-04-04 20:46:28 +03:00
d3m0k1d d535831fc1 fix: fcking activate account
ci-agent / build (push) Failing after 2m55s
2026-04-04 20:39:48 +03:00
d3m0k1d f8c413a498 fix: reg
ci-agent / build (push) Failing after 2m36s
2026-04-04 20:17:51 +03:00
zero@thinky 134777de10 feat(backend): add sqlite to dockerfile for manual intervention
ci-agent / build (push) Failing after 3m0s
2026-04-04 20:07:41 +03:00
NikitaTorbenko 400ceab47c fix: for crush
ci-front / build (push) Has been cancelled
2026-04-04 20:05:25 +03:00
zero@thinky 4ea1aec6e2 feat(backend): implement service monitor proto & connect it to http /agents
ci-agent / build (push) Failing after 2m30s
2026-04-04 20:01:30 +03:00
zero@thinky 1d75935a08 feat(proto): add service monitor
ci-agent / build (push) Failing after 2m30s
2026-04-04 19:56:08 +03:00
NikitaTorbenko c6a9907822 feat
ci-front / build (push) Successful in 2m26s
2026-04-04 19:49:37 +03:00
d3m0k1d 0f8b148279 fix: linter and docs
ci-agent / build (push) Failing after 2m50s
2026-04-04 19:44:16 +03:00
zero@thinky fe7e41e4af fix(commander): missing job id on errors
ci-agent / build (push) Failing after 3m4s
2026-04-04 19:32:04 +03:00
zero@thinky 81d8f71937 feat(backend): drop default on jobs 2026-04-04 19:32:04 +03:00
d3m0k1d a71fde67e4 fix: user reg
ci-agent / build (push) Failing after 3m0s
2026-04-04 18:49:05 +03:00
NikitaTorbenko 69ff617c30 fix: auth store
ci-front / build (push) Successful in 2m27s
2026-04-04 18:20:52 +03:00
zero@thinky 398c688fed fix race
ci-agent / build (push) Failing after 2m42s
2026-04-04 18:15:45 +03:00
nikita 3430070df8 feat: adminka
ci-front / build (push) Successful in 2m11s
2026-04-04 18:13:54 +03:00
zero@thinky 958211198c feat(backend): add cors 2026-04-04 17:53:35 +03:00
nikita 78f35f6811 feat: dashboard-page
ci-front / build (push) Successful in 2m7s
2026-04-04 16:53:12 +03:00
zero@thinky 0660117c07 docs(backend): add swag to jobs
ci-agent / build (push) Failing after 2m56s
2026-04-04 16:51:18 +03:00
zero@thinky 9ede6257f8 feat(backend): use interpreters service for jobs handlers 2026-04-04 16:51:18 +03:00
zero@thinky f5b9b32a9f feat(backend): add script interpreters 2026-04-04 16:51:18 +03:00
zero@thinky e721cff3f8 refactor(agent): error handling 2026-04-04 16:51:18 +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
146 changed files with 31680 additions and 1455 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.GITEA_TOKEN }}
+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
```
+64
View File
@@ -0,0 +1,64 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: BanForge
gitea_urls:
api: https://gitea.d3m0k1d.ru/api/v1
download: https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/download
skip_tls_verify: false
builds:
- id: banforge
main: ./main.go
binary: banforge
ignore:
- goos: windows
- goos: darwin
- goos: freebsd
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- "-s -w"
env:
- CGO_ENABLED=0
nfpms:
- id: banforge
package_name: banforge
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
homepage: https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN
description: HellreigN agent
maintainer: d3m0k1d <contact@d3m0k1d.ru>
license: GPLv3.0
formats:
- deb
- rpm
bindir: /usr/bin
release:
gitea:
owner: d3m0k1d
name: BanForge
mode: keep-existing
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
sboms:
- artifacts: any
documents:
- "{{ .ArtifactName }}.spdx.json"
cmd: syft
args: ["$artifact", "--output", "spdx-json=$document"]
+54 -1
View File
@@ -3,15 +3,67 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent
go 1.26.1
require (
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260404174628-3389df740c20
github.com/hpcloud/tail v1.0.0
github.com/moby/moby/api v1.54.1
github.com/moby/moby/client v0.4.0
github.com/samber/lo v1.53.0
golang.org/x/sync v0.20.0
google.golang.org/grpc v1.80.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.3
k8s.io/apimachinery v0.35.3
modernc.org/sqlite v1.34.5
)
require (
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 (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -28,6 +80,7 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
k8s.io/client-go v0.35.3
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
+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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.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/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/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/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
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/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/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/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
@@ -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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
@@ -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/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+19 -18
View File
@@ -3,22 +3,19 @@ package commander
import (
"bytes"
"errors"
"io"
"os/exec"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"golang.org/x/sync/errgroup"
"io"
"os/exec"
)
type CommandExecutor struct {
}
type CommandExecutor struct{}
func (*CommandExecutor) Execute(command *proto.Command) (*proto.FinishedCommand, error) {
func (*CommandExecutor) Execute(command *proto.Command) (fc *proto.FinishedCommand, err error) {
fc = new(proto.FinishedCommand)
fc.Id = command.Id
cmd := exec.Command(command.Command[0], command.Command[1:]...)
var (
stdin io.WriteCloser
err error
)
var stdin io.WriteCloser
if command.Stdin != nil {
stdin, err = cmd.StdinPipe()
if err != nil {
@@ -50,16 +47,20 @@ func (*CommandExecutor) Execute(command *proto.Command) (*proto.FinishedCommand,
_, err := io.Copy(stderrbuf, stderr)
return err
})
if err := cmd.Wait(); err != nil {
return nil, err
if waitErr := cmd.Wait(); waitErr != nil {
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 {
return nil, err
}
return &proto.FinishedCommand{
Id: command.Id,
Status: int32(cmd.ProcessState.ExitCode()),
Stdout: stdoutbuf.String(),
Stderr: stderrbuf.String(),
}, nil
fc.Status = int32(cmd.ProcessState.ExitCode())
fc.Stdout = stdoutbuf.String()
fc.Stderr = stderrbuf.String()
return
}
+9 -3
View File
@@ -8,9 +8,10 @@ import (
)
type ServiceConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Path *string `yaml:"path"`
Name string `yaml:"name"`
Type string `yaml:"type"`
Path *string `yaml:"path"`
SystemdUnit *string `yaml:"systemd_unit"` // Optional: systemd unit name for health check
}
type AgentConfig struct {
@@ -20,6 +21,11 @@ type AgentConfig struct {
Label string `yaml:"label"`
CertDir string `yaml:"cert_dir"`
Services []ServiceConfig `yaml:"services"`
MonitorDocker bool `yaml:"monitor_docker"`
MonitorKubernetes bool `yaml:"monitor_kubes"`
KubernetesNamespace *string `yaml:"kubernetes_namespace"`
}
func Load(path string) (*AgentConfig, error) {
+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
}
}
+255 -86
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
@@ -12,16 +13,20 @@ import (
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/client"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
agentmetrics "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/metrics"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/docker"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/kubernetes"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"github.com/samber/lo"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
)
@@ -97,108 +102,122 @@ func main() {
wg := &errgroup.Group{}
grpcAddr := cfg.GRPCURL
if grpcAddr == "" {
grpcAddr = cfg.BackendURL
}
grpcAddr = strings.TrimPrefix(grpcAddr, "http://")
grpcAddr = strings.TrimPrefix(grpcAddr, "https://")
// Start command executor
wg.Go(func() error {
cmdexe := new(commander.CommandExecutor)
ccli := client.New(cmdexe, cfg.Label, cfg.Label)
grpcAddr := cfg.GRPCURL
if grpcAddr == "" {
grpcAddr = cfg.BackendURL
}
grpcAddr = strings.TrimPrefix(grpcAddr, "http://")
grpcAddr = strings.TrimPrefix(grpcAddr, "https://")
return ccli.HandleCommands(ctx, grpcAddr, creds)
})
// Start services update stream
if len(cfg.Services) > 0 {
wg.Go(func() error {
return reportServices(ctx, grpcAddr, creds, cfg.Label, cfg.Services, lgr)
})
}
// Start system metrics reporting
wg.Go(func() error {
return reportSystemMetrics(ctx, grpcAddr, creds, cfg.Label, lgr)
})
// Start log collectors
if len(cfg.Services) > 0 {
grpcAddr := cfg.GRPCURL
if grpcAddr == "" {
grpcAddr = cfg.BackendURL
}
grpcAddr = strings.TrimPrefix(grpcAddr, "http://")
grpcAddr = strings.TrimPrefix(grpcAddr, "https://")
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
if err != nil {
lgr.Error("Failed to connect to gRPC", "err", err)
os.Exit(1)
}
defer func() { _ = conn.Close() }()
ccli := proto.NewCollectorClient(conn)
for _, svc := range cfg.Services {
svc := svc
var src logsource.LogSource
switch svc.Type {
case "journald":
src, err = journald.New(svc, os.Getenv("JOURNALD_LOGDIR"))
if err != nil {
lgr.Error("Failed to create journald source", "service", svc.Name, "err", err)
os.Exit(1)
}
case "file":
if svc.Path == nil {
lgr.Error("Path is required for file log source", "service", svc.Name)
os.Exit(1)
}
src, err = file.New(*svc.Path)
if err != nil {
lgr.Error("Failed to create file source", "service", svc.Name, "err", err)
os.Exit(1)
}
default:
lgr.Error("Unknown log source type", "type", svc.Type, "service", svc.Name)
os.Exit(1)
wg.Go(func() error {
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
if err != nil {
return fmt.Errorf("failed to connect to gRPC: %w", err)
}
defer func() { _ = conn.Close() }()
wg.Go(func() error {
lgr.Info("Starting log stream", "service", svc.Name)
ccli := proto.NewCollectorClient(conn)
// First, flush any buffered logs from offline period
if err := flushBufferedLogs(ctx, ccli, logBuf, svc.Name, cfg.Label, cfg.RegistrationToken, lgr); err != nil {
lgr.Error("Failed to flush buffered logs", "service", svc.Name, "err", err)
}
scli, err := ccli.Stream(
metadata.NewOutgoingContext(ctx, metadata.MD{
"whoami": []string{cfg.Label},
"service": []string{svc.Name},
"token": []string{cfg.RegistrationToken},
"services": lo.Map(cfg.Services, func(item config.ServiceConfig, _ int) string {
return item.Name
}),
}),
)
if err != nil {
return fmt.Errorf("failed to create stream: %w", err)
}
for {
line, err := src.ReadLine()
svcWg := new(errgroup.Group)
for _, svc := range cfg.Services {
svc := svc
var src logsource.LogSource
switch svc.Type {
case "journald":
src, err = journald.New(svc, os.Getenv("JOURNALD_LOGDIR"))
if err != nil {
lgr.Error("ReadLine error", "service", svc.Name, "err", err)
return err
return fmt.Errorf("failed to create journald source %q: %w", svc.Name, err)
}
case "file":
if svc.Path == nil {
return fmt.Errorf("path is required for file log source %q", svc.Name)
}
src, err = file.New(*svc.Path)
if err != nil {
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
}
case "docker":
src, err = docker.New(svc)
if err != nil {
return fmt.Errorf("failed to create docker source for container %q: %w", svc.Name, err)
}
case "kubernetes":
src, err = kubernetes.New(svc)
if err != nil {
return fmt.Errorf("failed to create kubernetes source for pod %q: %w", svc.Name, err)
}
default:
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
}
svcWg.Go(func() error {
lgr.Info("Starting log stream", "service", svc.Name)
// First, flush any buffered logs from offline period
if err := flushBufferedLogs(ctx, ccli, logBuf, svc.Name, cfg.Label, cfg.RegistrationToken, lgr); err != nil {
lgr.Error("Failed to flush buffered logs", "service", svc.Name, "err", err)
}
if err := scli.Send(&proto.CollectorRequest{
Message: line,
}); err != nil {
// Connection failed, buffer the log
lgr.Warn("Send failed, buffering log", "service", svc.Name, "err", err)
if storeErr := logBuf.Store(svc.Name, line); storeErr != nil {
lgr.Error("Failed to buffer log", "service", svc.Name, "err", storeErr)
}
// Try to reconnect
if reconnectErr := reconnectStream(ctx, &scli, ccli, svc.Name, cfg.Label, cfg.RegistrationToken, logBuf, lgr); reconnectErr != nil {
return reconnectErr
}
continue
scli, err := ccli.Stream(
metadata.NewOutgoingContext(ctx, metadata.MD{
"whoami": []string{cfg.Label},
"service": []string{svc.Name},
"token": []string{cfg.RegistrationToken},
"services": lo.Map(cfg.Services, func(item config.ServiceConfig, _ int) string {
return item.Name
}),
}),
)
if err != nil {
return fmt.Errorf("failed to create stream: %w", err)
}
}
})
}
for {
line, err := src.ReadLine()
if err != nil {
lgr.Error("ReadLine error", "service", svc.Name, "err", err)
return err
}
if err := scli.Send(&proto.CollectorRequest{
Message: line,
}); err != nil {
// Connection failed, buffer the log
lgr.Warn("Send failed, buffering log", "service", svc.Name, "err", err)
if storeErr := logBuf.Store(svc.Name, line); storeErr != nil {
lgr.Error("Failed to buffer log", "service", svc.Name, "err", storeErr)
}
// Try to reconnect
if reconnectErr := reconnectStream(ctx, &scli, ccli, svc.Name, cfg.Label, cfg.RegistrationToken, logBuf, lgr); reconnectErr != nil {
return reconnectErr
}
continue
}
}
})
}
return svcWg.Wait()
})
}
if err := wg.Wait(); err != nil {
@@ -309,3 +328,153 @@ func reconnectStream(
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:
}
}
}
+119 -10
View File
@@ -15,6 +15,7 @@ import (
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"github.com/gin-gonic/gin"
@@ -81,10 +82,35 @@ func main() {
}()
}
// Initialize Collector gRPC service
coll := collector.New(logRepo)
// Initialize Collector (log streaming) with its own ConnTracker
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
scriptRepo := repository.NewScriptInterpreterRepo(db)
if err := scriptRepo.Init(context.Background()); err != nil {
log.Fatalf("Warning: failed to initialize script interpreters table: %v\n", err)
}
scriptSvc := service.NewScriptServiceWithInterpreters(h.Repo, scriptRepo)
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdTracker,
os.Getenv("WHEREAMI"))
jobsHandlers := handlers.NewJobsHandlers(cmdTracker, scriptSvc,
os.Getenv("WHEREAMI"), /* our address for redirects */
jobRepo,
)
scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptSvc, cmdr,
os.Getenv("WHEREAMI"))
graphPath := os.Getenv("GRAPH_YAML_PATH")
if graphPath == "" {
graphPath = "/etc/hellreign/services.yaml"
}
graphHandlers := handlers.NewGraphHandlers(graphPath, coll)
agents := handlers.NewAgentsGroup(h, coll)
auth := handlers.AuthGroup{Handlers: h}
@@ -120,6 +146,7 @@ func main() {
}
router := gin.Default()
router.Use(handlers.CorsMiddleware("http://127.0.0.1:5173;http://localhost:5173"))
docs.SwaggerInfo.BasePath = "/api/v1"
docs.SwaggerInfo.Title = "HellreigN"
docs.SwaggerInfo.Version = "1.0"
@@ -133,13 +160,14 @@ func main() {
authGroup := v1.Group("/auth")
{
authGroup.POST("/login", auth.Login)
authGroup.POST("/register", auth.RegisterUser)
}
// Auth token management (requires auth)
authTokenGroup := v1.Group("/auth")
authTokenGroup.Use(auth.AuthMiddleware())
{
authTokenGroup.POST("/token", handlers.RequireAdmin(), auth.CreateToken)
authTokenGroup.POST("/token", auth.CreateToken)
authTokenGroup.GET("/validate", auth.ValidateToken)
authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens)
authTokenGroup.DELETE("/token", auth.DeleteMyToken)
@@ -148,12 +176,28 @@ func main() {
// User management (admin only) - Full CRUD
authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser)
authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser)
authTokenGroup.PUT("/users/:login/permissions", handlers.RequireAdmin(), auth.UpdateUserPermissions)
authTokenGroup.PUT("/users/:login/password", handlers.RequireAdmin(), auth.ResetUserPassword)
authTokenGroup.PUT(
"/users/:login/permissions",
handlers.RequireAdmin(),
auth.UpdateUserPermissions,
)
authTokenGroup.PUT(
"/users/:login/password",
handlers.RequireAdmin(),
auth.ResetUserPassword,
)
// User activation management (admin only)
authTokenGroup.POST("/users/:login/activate", handlers.RequireAdmin(), auth.ActivateUser)
authTokenGroup.POST("/users/:login/deactivate", handlers.RequireAdmin(), auth.DeactivateUser)
authTokenGroup.POST(
"/users/:login/activate",
handlers.RequireAdmin(),
auth.ActivateUser,
)
authTokenGroup.POST(
"/users/:login/deactivate",
handlers.RequireAdmin(),
auth.DeactivateUser,
)
authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers)
}
@@ -162,6 +206,37 @@ func main() {
agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
{
agentsGroup.GET("", agents.List)
agentsGroup.GET("/system-metrics", agents.GetSystemMetrics)
}
// Jobs (requires admin permission)
jobsGroup := v1.Group("/jobs")
jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
{
jobsGroup.POST("", jobsHandlers.AddJob)
jobsGroup.POST("/:id/wait", jobsHandlers.WaitJob)
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
}
// Service dependency graph
graphGroup := v1.Group("/graph")
graphGroup.Use(auth.AuthMiddleware())
{
// Read-only endpoints: GET (require view)
graphView := graphGroup.Group("")
graphView.Use(handlers.RequireView())
{
graphView.GET("", graphHandlers.GetGraph)
graphView.GET("/order", graphHandlers.StartupOrder)
graphView.GET("/cycle", graphHandlers.CycleCheck)
graphView.GET("/failure", graphHandlers.GetFailureRootCause)
}
// Write endpoints: PUT (require admin)
graphAdmin := graphGroup.Group("")
graphAdmin.Use(handlers.RequireAdmin())
{
graphAdmin.PUT("", graphHandlers.UpdateYAML)
}
}
// Agent registration
@@ -193,6 +268,36 @@ func main() {
logsGroup.GET("/agents", logHandlers.GetAgents)
logsGroup.GET("/levels", logHandlers.GetLevels)
}
// Scripts (requires admin permission)
scriptsGroup := v1.Group("/scripts")
scriptsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
{
scriptsGroup.POST("/run", scriptHandlers.RunScript)
scriptsGroup.GET("/interpreters", scriptHandlers.ListInterpreters)
scriptsGroup.POST("/interpreters", scriptHandlers.CreateInterpreter)
scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter)
scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter)
scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter)
// Script management (tree, CRUD)
scriptsGroup.GET("/tree", scriptManageHandlers.GetTree)
scriptsGroup.POST("", scriptManageHandlers.CreateScript)
scriptsGroup.GET("/:id", scriptManageHandlers.GetScript)
scriptsGroup.PUT("/:id", scriptManageHandlers.UpdateScript)
scriptsGroup.DELETE("/:id", scriptManageHandlers.DeleteScript)
scriptsGroup.POST("/:id/run", scriptManageHandlers.RunScriptByID)
// Folder management
scriptsGroup.POST("/folder", scriptManageHandlers.CreateFolder)
scriptsGroup.DELETE("/folder", scriptManageHandlers.DeleteFolder)
// Rename script or folder
scriptsGroup.POST("/rename", scriptManageHandlers.Rename)
// Get script by path
scriptsGroup.GET("/by-path", scriptManageHandlers.GetScriptByPath)
}
}
// Start gRPC server with mTLS in background
@@ -231,7 +336,11 @@ func main() {
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.RegisterCollectorServer(grpcServer, coll)
@@ -270,4 +379,4 @@ func main() {
if err := g.Wait(); err != nil {
log.Fatalf("Server error: %v", err)
}
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM alpine:3.23.0
RUN apk add --no-cache curl openssl bash ansible
RUN apk add --no-cache curl openssl bash ansible sqlite
COPY --from=builder /app/backend/backend .
COPY --from=builder /app/backend/scripts /etc/hellreign/scripts
+1726 -125
View File
File diff suppressed because it is too large Load Diff
+1726 -125
View File
File diff suppressed because it is too large Load Diff
+1120 -96
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -3,9 +3,10 @@ module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend
go 1.26.1
require (
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260404174628-3389df740c20
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/gin-gonic/gin v1.12.0
github.com/samber/lo v1.53.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
+2
View File
@@ -138,6 +138,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+74 -19
View File
@@ -7,42 +7,49 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
// ErrUnknownDeployType is returned when an unsupported deployment type is specified
var ErrUnknownDeployType = fmt.Errorf("unknown deploy type, expected 'docker' or 'binary'")
// Executor handles running Ansible playbooks
type Executor struct {
workDir string
grpcServerHost string
grpcServerPort string
backendURL string
workDir string
grpcServerHost string
grpcServerPort string
backendURL string
giteaReleasesURL string
}
// ExecutorConfig holds configuration for the Executor
type ExecutorConfig struct {
WorkDir string
GRPCServerHost string
GRPCServerPort string
BackendURL string
WorkDir string
GRPCServerHost string
GRPCServerPort string
BackendURL string
GiteaReleasesURL string
}
// NewExecutor creates a new Ansible executor
func NewExecutor(cfg ExecutorConfig) *Executor {
return &Executor{
workDir: cfg.WorkDir,
grpcServerHost: cfg.GRPCServerHost,
grpcServerPort: cfg.GRPCServerPort,
backendURL: cfg.BackendURL,
workDir: cfg.WorkDir,
grpcServerHost: cfg.GRPCServerHost,
grpcServerPort: cfg.GRPCServerPort,
backendURL: cfg.BackendURL,
giteaReleasesURL: cfg.GiteaReleasesURL,
}
}
// DeployResult holds the result of a deployment
type DeployResult struct {
Host string
Success bool
Stdout string
Stderr string
Err error
Host string
Success bool
Stdout string
Stderr string
Err error
}
// WorkDir returns the work directory path
@@ -50,8 +57,47 @@ func (e *Executor) WorkDir() string {
return e.workDir
}
// GRPCURL returns the gRPC server URL (host:port)
func (e *Executor) GRPCURL() string {
return e.grpcServerHost + ":" + e.grpcServerPort
}
// CheckDockerCollection verifies that the community.docker Ansible collection is installed.
// Returns an error if the collection is not found.
func (e *Executor) CheckDockerCollection() error {
cmd := exec.Command("ansible-galaxy", "collection", "list", "community.docker")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("community.docker collection not found: %s", stderr.String())
}
// ansible-galaxy collection list returns output like:
// # /usr/share/ansible/collections/ansible_collections
// Collection Version
// ---------------- -------
// community.docker 3.10.0
//
// If the collection is not installed, it won't appear in the output.
if !strings.Contains(stdout.String(), "community.docker") {
return fmt.Errorf("community.docker collection is not installed. Run: ansible-galaxy collection install community.docker")
}
return nil
}
// Deploy runs Ansible playbook for the given inventory
func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType string) ([]DeployResult, error) {
func (e *Executor) Deploy(
ctx context.Context,
inventoryPath string,
deployType string,
) ([]DeployResult, error) {
if deployType != "docker" && deployType != "binary" {
return nil, fmt.Errorf("invalid deploy type %q: %w", deployType, ErrUnknownDeployType)
}
playbookName := "binary_deploy.yml"
if deployType == "docker" {
playbookName = "docker_deploy.yml"
@@ -62,6 +108,8 @@ func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType
cmd := exec.CommandContext(ctx, "ansible-playbook",
"-i", inventoryPath,
"-e", fmt.Sprintf("backend_url=%s", e.backendURL),
"-e", fmt.Sprintf("grpc_url=%s", e.grpcServerHost+":"+e.grpcServerPort),
"-e", fmt.Sprintf("gitea_releases_url=%s", e.giteaReleasesURL),
playbookPath,
)
@@ -84,8 +132,13 @@ func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType
}
// DeployParallel runs Ansible playbook for multiple inventories in parallel
func (e *Executor) DeployParallel(ctx context.Context, inventoryPaths []string, deployType string) (map[string][]DeployResult, error) {
func (e *Executor) DeployParallel(
ctx context.Context,
inventoryPaths []string,
deployType string,
) (map[string][]DeployResult, error) {
var wg sync.WaitGroup
var mu sync.Mutex
results := make(map[string][]DeployResult)
errCh := make(chan error, len(inventoryPaths))
@@ -97,7 +150,9 @@ func (e *Executor) DeployParallel(ctx context.Context, inventoryPaths []string,
if err != nil {
errCh <- err
}
mu.Lock()
results[p] = res
mu.Unlock()
}(path)
}
+8 -9
View File
@@ -18,6 +18,7 @@ type InventoryHost struct {
Password string
DeployType string
Token string
GRPCURL string
}
// Inventory represents an Ansible inventory file
@@ -25,15 +26,13 @@ type Inventory struct {
Hosts []InventoryHost
}
const inventoryTemplateText = `{{ range .Hosts }}
{{ .Name }} ansible_host={{ .IP }} ansible_port={{ .Port }} ansible_user={{ .User }} ansible_connection=ssh
{{ if eq .AuthMethod "key" }}ansible_ssh_private_key_file={{ .SSHKey }}{{ end }}
{{ if eq .AuthMethod "password" }}ansible_ssh_pass={{ .Password }}{{ end }}
deploy_type={{ .DeployType }}
agent_token={{ .Token }}
agent_label={{ .Name }}
{{ end }}`
const inventoryTemplateText = `{{- range $i, $host := .Hosts }}
{{ $host.Name }} ansible_host={{ $host.IP }} ansible_port={{ $host.Port }} ansible_user={{ $host.User }} ansible_connection=ssh{{ if eq $host.AuthMethod "key" }} ansible_ssh_private_key_file={{ $host.SSHKey }}{{ end }}{{ if eq $host.AuthMethod "password" }} ansible_ssh_pass={{ $host.Password }}{{ end }}
deploy_type={{ $host.DeployType }}
agent_token={{ $host.Token }}
agent_label={{ $host.Name }}
grpc_url={{ $host.GRPCURL }}
{{ end -}}`
// GenerateInventory generates an Ansible inventory file from the given hosts
func GenerateInventory(hosts []InventoryHost, outputPath string) error {
+39 -12
View File
@@ -1,6 +1,7 @@
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 installs a systemd unit for automatic restart.
const BinaryDeployPlaybook = `---
- name: Deploy HellreigN Agent (Binary)
hosts: all
@@ -11,8 +12,8 @@ const BinaryDeployPlaybook = `---
backend_url: "{{ backend_url }}"
install_dir: /opt/hellreign
bin_name: hellreign-agent
service_name: hellreign-agent
cert_dir: "{{ install_dir }}/certs"
gitea_releases_url: "{{ gitea_releases_url | default('https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download') }}"
tasks:
- name: Create installation directory
@@ -29,7 +30,7 @@ const BinaryDeployPlaybook = `---
- name: Download HellreigN Agent binary
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 }}"
mode: '0755'
@@ -37,18 +38,23 @@ const BinaryDeployPlaybook = `---
copy:
content: |
backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
label: "{{ agent_label }}"
registration_token: "{{ agent_token }}"
cert_dir: "{{ cert_dir }}"
services:
- name: system
type: journald
dest: "{{ install_dir }}/config.yml"
mode: '0644'
- name: Create systemd service file
- name: Create systemd unit file
copy:
content: |
[Unit]
Description=HellreigN Agent
After=network.target
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
@@ -56,12 +62,10 @@ const BinaryDeployPlaybook = `---
Restart=always
RestartSec=5
Environment=CONFIG_FILE={{ install_dir }}/config.yml
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
dest: /etc/systemd/system/{{ service_name }}.service
dest: /etc/systemd/system/hellreign-agent.service
mode: '0644'
- name: Reload systemd daemon
@@ -70,12 +74,20 @@ const BinaryDeployPlaybook = `---
- name: Enable and start HellreigN Agent service
systemd:
name: "{{ service_name }}"
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.
const DockerDeployPlaybook = `---
- name: Deploy HellreigN Agent (Docker)
hosts: all
@@ -84,9 +96,12 @@ const DockerDeployPlaybook = `---
agent_label: "{{ agent_label }}"
agent_token: "{{ agent_token }}"
backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
container_name: hellreign-agent-{{ agent_label }}
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
install_dir: /opt/hellreign
cert_dir: /etc/hellreign-agent/certs
config_dir: /etc/hellreign-agent
tasks:
- name: Install Docker (if not present)
@@ -108,6 +123,12 @@ const DockerDeployPlaybook = `---
state: directory
mode: '0755'
- name: Create configuration directory
file:
path: "{{ config_dir }}"
state: directory
mode: '0755'
- name: Pull HellreigN Agent image
community.docker.docker_image:
name: "{{ image }}"
@@ -117,10 +138,15 @@ const DockerDeployPlaybook = `---
copy:
content: |
backend_url: "{{ backend_url }}"
grpc_url: "{{ grpc_url | default('localhost:9001') }}"
label: "{{ agent_label }}"
registration_token: "{{ agent_token }}"
cert_dir: "{{ cert_dir }}"
dest: "{{ cert_dir }}/config.yml"
services:
- name: "{{ agent_label }}"
type: docker
path: "{{ container_name }}"
dest: "{{ config_dir }}/config.yml"
mode: '0644'
- name: Create and run HellreigN Agent container
@@ -131,6 +157,7 @@ const DockerDeployPlaybook = `---
restart_policy: always
volumes:
- "{{ cert_dir }}:/etc/hellreign-agent/certs"
- "{{ config_dir }}/config.yml:/etc/hellreign-agent/config.yml:ro"
env:
CONFIG_FILE: /etc/hellreign-agent/certs/config.yml
CONFIG_FILE: /etc/hellreign-agent/config.yml
`
+2 -3
View File
@@ -1,5 +1,4 @@
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)
}
+27 -43
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"io"
"log"
"sync"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
@@ -13,26 +12,19 @@ import (
"google.golang.org/grpc/metadata"
)
// Collector handles log streaming from connected agents.
type Collector struct {
proto.UnimplementedCollectorServer
logRepo *repository.LogRepository
agents map[string]*Agent
mu sync.RWMutex
tracker *ConnTracker
batchSize int
flushInterval time.Duration
}
type Agent struct {
ID string
Label string
Services []string
ConnectedAt time.Time
}
func New(logRepo *repository.LogRepository) *Collector {
func New(logRepo *repository.LogRepository, tracker *ConnTracker) *Collector {
return &Collector{
logRepo: logRepo,
agents: make(map[string]*Agent),
tracker: tracker,
batchSize: 100,
flushInterval: 2 * time.Second,
}
@@ -56,33 +48,24 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
}
service := serviceVals[0]
servicesVals := md["services"]
var services []string
if len(servicesVals) > 0 {
services = servicesVals
}
// Register agent
c.mu.Lock()
c.agents[agentName] = &Agent{
agent := &Agent{
ID: agentName,
Label: agentName,
Services: services,
Services: make([]Service, 0),
ConnectedAt: time.Now(),
}
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.agents, agentName)
c.mu.Unlock()
}()
c.tracker.Register(agent)
defer c.tracker.Unregister(agent.ID)
log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service)
// If no ClickHouse, just consume the stream without storing
if !c.logRepo.IsConnected() {
log.Printf("Warning: ClickHouse not connected yet, consuming logs without storing for agent %s", agentName)
log.Printf(
"Warning: ClickHouse not connected yet, consuming logs without storing for agent %s",
agentName,
)
for {
_, err := stream.Recv()
if err == io.EOF {
@@ -120,7 +103,12 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
return nil
}
if err := c.logRepo.InsertBatch(stream.Context(), batch); err != nil {
log.Printf("Failed to insert batch for agent %s, service %s: %v", agentName, service, err)
log.Printf(
"Failed to insert batch for agent %s, service %s: %v",
agentName,
service,
err,
)
return err
}
log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service)
@@ -131,7 +119,6 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
for {
select {
case <-stream.Context().Done():
// Context cancelled, flush remaining
_ = flush()
return stream.Context().Err()
case <-ticker.C:
@@ -154,7 +141,6 @@ func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
}
case err := <-errCh:
if err == io.EOF {
// Client closed stream
return flush()
}
return fmt.Errorf("failed to receive: %w", err)
@@ -162,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) {
c.mu.RLock()
defer c.mu.RUnlock()
a, ok := c.agents[name]
return a, ok
return c.tracker.GetAgent(name)
}
// Agents delegates to the tracker.
func (c *Collector) Agents() []*Agent {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*Agent, 0, len(c.agents))
for _, a := range c.agents {
result = append(result, a)
}
return result
return c.tracker.Agents()
}
// GetSystemMetrics delegates to the tracker.
func (c *Collector) GetSystemMetrics() map[string]AgentMetricsInfo {
return c.tracker.GetSystemMetrics()
}
@@ -0,0 +1,92 @@
package collector
import (
"context"
"fmt"
"log"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
"google.golang.org/grpc/metadata"
)
// ReportServices handles a unary service status update from an agent.
// Agents send their current services list, which is stored in the collector.
func (c *Collector) ReportServices(ctx context.Context, req *proto.ServicesUpdate) (*proto.ServicesUpdateResp, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, fmt.Errorf("no metadata in context")
}
whoamiVals := md["whoami"]
if len(whoamiVals) == 0 {
return nil, fmt.Errorf("whoami metadata missing")
}
agentName := whoamiVals[0]
// Auto-register agent if not yet known (e.g. log stream not connected yet)
c.ensureAgentRegistered(agentName)
services := make([]Service, 0, len(req.Services))
for _, s := range req.Services {
services = append(services, Service{s.Name, s.Status})
}
if ok := c.tracker.UpdateServices(agentName, services); ok {
log.Printf("Updated services for agent %s: %v", agentName, services)
} else {
log.Printf("Warning: received services update for unknown agent %s", agentName)
}
return &proto.ServicesUpdateResp{}, nil
}
// ReportSystemMetrics handles system metrics update from an agent.
// Agents send their current system metrics (CPU, RAM, disk, network).
func (c *Collector) ReportSystemMetrics(ctx context.Context, req *proto.SystemMetrics) (*proto.SystemMetricsResp, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, fmt.Errorf("no metadata in context")
}
whoamiVals := md["whoami"]
if len(whoamiVals) == 0 {
return nil, fmt.Errorf("whoami metadata missing")
}
agentName := whoamiVals[0]
// Auto-register agent if not yet known (e.g. log stream not connected yet)
c.ensureAgentRegistered(agentName)
metrics := SystemMetrics{
CPUPercent: req.CpuPercent,
MemoryPercent: req.MemoryPercent,
DiskPercent: req.DiskPercent,
NetworkRxBytes: req.NetworkRxBytes,
NetworkTxBytes: req.NetworkTxBytes,
}
if ok := c.tracker.UpdateSystemMetrics(agentName, metrics); ok {
log.Printf("Updated system metrics for agent %s: CPU=%.1f%%, RAM=%.1f%%, Disk=%.1f%%",
agentName, metrics.CPUPercent, metrics.MemoryPercent, metrics.DiskPercent)
} else {
log.Printf("Warning: received system metrics for unknown agent %s", agentName)
}
return &proto.SystemMetricsResp{}, nil
}
// ensureAgentRegistered registers the agent in the tracker if it's not already there.
// This handles the case where agents send metrics/services before connecting to the log stream.
func (c *Collector) ensureAgentRegistered(agentName string) {
if _, ok := c.tracker.GetAgent(agentName); !ok {
agent := &Agent{
ID: agentName,
Label: agentName,
Services: make([]Service, 0),
ConnectedAt: time.Now(),
}
c.tracker.Register(agent)
log.Printf("Auto-registered agent via unary RPC: %s", agentName)
}
}
@@ -0,0 +1,165 @@
package collector
import (
"context"
"log"
"sync"
"time"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/stats"
)
// ConnTracker tracks connected Collector agents and handles cleanup on disconnect.
// It implements grpc.StatsHandler for disconnect detection.
type ConnTracker struct {
mu sync.RWMutex
agents map[string]*Agent
}
func NewConnTracker() *ConnTracker {
return &ConnTracker{
agents: make(map[string]*Agent),
}
}
// Register adds an agent to the tracker. Called by Collector.Stream().
func (t *ConnTracker) Register(agent *Agent) {
t.mu.Lock()
t.agents[agent.ID] = agent
t.mu.Unlock()
log.Printf("[collector] agent registered: %s", agent.ID)
}
// Unregister removes an agent from the tracker.
func (t *ConnTracker) Unregister(id string) {
t.mu.Lock()
delete(t.agents, id)
t.mu.Unlock()
log.Printf("[collector] agent unregistered: %s", id)
}
// GetAgent returns the agent for the given ID.
func (t *ConnTracker) GetAgent(id string) (*Agent, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
a, ok := t.agents[id]
return a, ok
}
// Agents returns all connected agents.
func (t *ConnTracker) Agents() []*Agent {
t.mu.RLock()
defer t.mu.RUnlock()
result := make([]*Agent, 0, len(t.agents))
for _, a := range t.agents {
result = append(result, a)
}
return result
}
// grpc.StatsHandler implementation.
func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
return ctx
}
func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
return ctx
}
func (t *ConnTracker) HandleConn(ctx context.Context, s stats.ConnStats) {
switch s.(type) {
case *stats.ConnEnd:
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return
}
whoamiVals := md["whoami"]
if len(whoamiVals) == 0 {
return
}
t.Unregister(whoamiVals[0])
}
}
// UpdateServices updates the services list for the given agent.
func (t *ConnTracker) UpdateServices(id string, services []Service) bool {
t.mu.Lock()
defer t.mu.Unlock()
agent, ok := t.agents[id]
if !ok {
return false
}
agent.Services = services
return true
}
// UpdateSystemMetrics updates the system metrics for the given agent.
func (t *ConnTracker) UpdateSystemMetrics(id string, metrics SystemMetrics) bool {
t.mu.Lock()
defer t.mu.Unlock()
agent, ok := t.agents[id]
if !ok {
return false
}
agent.SystemMetrics = metrics
return true
}
// GetSystemMetrics returns system metrics for all connected agents.
func (t *ConnTracker) GetSystemMetrics() map[string]AgentMetricsInfo {
t.mu.RLock()
defer t.mu.RUnlock()
result := make(map[string]AgentMetricsInfo)
for id, agent := range t.agents {
result[id] = AgentMetricsInfo{
ID: id,
Label: agent.Label,
ConnectedAt: agent.ConnectedAt,
CPUPercent: agent.SystemMetrics.CPUPercent,
MemoryPercent: agent.SystemMetrics.MemoryPercent,
DiskPercent: agent.SystemMetrics.DiskPercent,
NetworkRxBytes: agent.SystemMetrics.NetworkRxBytes,
NetworkTxBytes: agent.SystemMetrics.NetworkTxBytes,
}
}
return result
}
// Service represents a named service with its current status.
type Service struct {
Name, Status string
}
// SystemMetrics represents system resource metrics.
type SystemMetrics struct {
CPUPercent float64
MemoryPercent float64
DiskPercent float64
NetworkRxBytes float64
NetworkTxBytes float64
}
// AgentMetricsInfo contains agent info with its system metrics.
type AgentMetricsInfo struct {
ID string `json:"id"`
Label string `json:"label"`
ConnectedAt time.Time `json:"connected_at"`
CPUPercent float64 `json:"cpu_percent"`
MemoryPercent float64 `json:"memory_percent"`
DiskPercent float64 `json:"disk_percent"`
NetworkRxBytes float64 `json:"network_rx_bytes"`
NetworkTxBytes float64 `json:"network_tx_bytes"`
}
// Agent represents a connected agent streaming logs to the collector.
type Agent struct {
ID string
Label string
Services []Service
SystemMetrics SystemMetrics
ConnectedAt time.Time
}
+134 -60
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"log"
"sync"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
@@ -11,27 +12,30 @@ import (
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/stats"
)
// Commander handles command execution on connected agents.
type Commander struct {
proto.UnimplementedCommanderServer
agents map[string]Agent
mu sync.RWMutex
jobber Jobber
tracker *ConnTracker
jobber Jobber
}
// Jobber persists job state.
type Jobber interface {
InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error)
UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error)
}
func New(jobber Jobber) *Commander {
func New(jobber Jobber, tracker *ConnTracker) *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 {
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]
in chan *proto.Command
@@ -40,10 +44,11 @@ type Agent struct {
ctx context.Context
aid string
Token string // agent id
Token string
Label string
Services []string
}
type JobOut struct {
fc models.Job
err error
@@ -53,48 +58,93 @@ type Job struct {
out chan JobOut
}
func (self *Commander) GetAgent(aid string) (agent Agent, ok bool) {
// ConnTracker tracks connected agents and handles cleanup on disconnect.
// It implements grpc.StatsHandler for disconnect detection.
type ConnTracker struct {
mu sync.RWMutex
agents map[string]*Agent
}
// GetAgentByLabel searches for an agent by its human-readable label.
func (self *ConnTracker) GetAgentByLabel(label string) (agent Agent, ok bool) {
self.mu.RLock()
defer self.mu.RUnlock()
agent, ok = self.agents[aid]
for _, a := range self.agents {
if a.Label == label {
return *a, true
}
}
return
}
func (self *Commander) Agents() []Agent {
self.mu.RLock()
defer self.mu.RUnlock()
result := make([]Agent, 0, len(self.agents))
for _, a := range self.agents {
func NewConnTracker() *ConnTracker {
return &ConnTracker{
agents: make(map[string]*Agent),
}
}
func (t *ConnTracker) Register(aid string, agent *Agent) {
t.mu.Lock()
t.agents[aid] = agent
t.mu.Unlock()
log.Printf("[conntracker] agent registered: %s", aid)
}
func (t *ConnTracker) Unregister(aid string) {
t.mu.Lock()
delete(t.agents, aid)
t.mu.Unlock()
log.Printf("[conntracker] agent unregistered: %s", aid)
}
func (t *ConnTracker) GetAgent(aid string) (*Agent, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
a, ok := t.agents[aid]
return a, ok
}
func (t *ConnTracker) Agents() []*Agent {
t.mu.RLock()
defer t.mu.RUnlock()
result := make([]*Agent, 0, len(t.agents))
for _, a := range t.agents {
result = append(result, a)
}
return result
}
func (self *Commander) removeAgent(aid string) {
self.mu.Lock()
defer self.mu.Unlock()
delete(self.agents, aid)
// grpc.StatsHandler implementation.
func (t *ConnTracker) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context {
return ctx
}
func (self *Agent) AddJob(job models.JobForInsert) (int64, error) {
jid, err := self.jobber.InitJob(self.ctx, self.aid, job)
if err != nil {
return 0, err
func (t *ConnTracker) HandleRPC(ctx context.Context, _ stats.RPCStats) {}
func (t *ConnTracker) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
return ctx
}
func (t *ConnTracker) HandleConn(ctx context.Context, s stats.ConnStats) {
switch s.(type) {
case *stats.ConnEnd:
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return
}
aidVals := md["agentid"]
if len(aidVals) == 0 {
return
}
t.Unregister(aidVals[0])
}
self.in <- &proto.Command{
Id: jid,
Command: job.Command,
Stdin: job.Stdin,
}
return jid, err
}
func (self *Agent) WaitJob(jid int64) (*models.Job, error) {
result := <-self.jobs[jid].out
return &result.fc, result.err
}
func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]) error {
// Stream handles a new agent connection and runs the send/recv loops.
func (c *Commander) Stream(
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command],
) error {
md, ok := metadata.FromIncomingContext(bidi.Context())
if !ok {
return fmt.Errorf("no metadata in context")
@@ -106,35 +156,58 @@ func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedComman
aid := aidVals[0]
var label string
labelVals := md["label"]
if len(labelVals) > 0 {
label = labelVals[0]
if vals := md["label"]; len(vals) > 0 {
label = vals[0]
}
agent := newAgent(bidi, self.jobber, aid, label)
self.mu.Lock()
self.agents[aid] = agent
self.mu.Unlock()
agent := NewAgent(bidi.Context(), c.jobber, aid, label)
agent.bidi = bidi
c.tracker.Register(aid, agent)
defer c.tracker.Unregister(aid)
defer self.removeAgent(aid)
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.Go(self.recv)
wg.Go(self.send)
wg.Go(a.recv)
wg.Go(a.send)
return wg.Wait()
}
func (self *Agent) recv() error {
func (a *Agent) recv() error {
for {
job, err := func() (job models.Job, err error) {
msg, err := self.bidi.Recv()
msg, err := a.bidi.Recv()
if err != nil {
return
}
return self.jobber.UpdateJobInDB(self.ctx, msg.Id, models.JobForUpdate{
return a.jobber.UpdateJobInDB(a.ctx, msg.Id, models.JobForUpdate{
Stdout: msg.Stdout,
Stderr: msg.Stderr,
Status: msg.Status,
@@ -143,8 +216,7 @@ func (self *Agent) recv() error {
if err == io.EOF {
return nil
}
// TODO: that would blow up at some point
out := self.jobs[job.ID].out
out := a.jobs[job.ID].out
out <- JobOut{
fc: job,
err: err,
@@ -153,24 +225,26 @@ func (self *Agent) recv() error {
}
}
func (self *Agent) send() error {
for job := range self.in {
self.jobs[job.Id] = newJob()
if err := self.bidi.Send(job); err != nil {
func (a *Agent) send() error {
for job := range a.in {
if err := a.bidi.Send(job); err != nil {
return err
}
}
return io.EOF
// self.jobs[]
}
func newAgent(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], jobber Jobber, aid string, label string) Agent {
return Agent{
bidi: bidi,
in: make(chan *proto.Command),
func NewAgent(
ctx context.Context,
jobber Jobber,
aid string,
label string,
) *Agent {
return &Agent{
in: make(chan *proto.Command, 10),
jobs: make(map[int64]Job),
jobber: jobber,
ctx: bidi.Context(),
ctx: ctx,
aid: aid,
Label: label,
Token: aid,
+68 -6
View File
@@ -29,22 +29,33 @@ func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
grpcPort = "9001"
}
grpcHost := os.Getenv("GRPC_SERVER_HOST")
if grpcHost == "" {
grpcHost = "0.0.0.0"
}
backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" {
backendURL = "http://localhost:8080"
}
giteaURL := os.Getenv("GITEA_RELEASES_URL")
if giteaURL == "" {
giteaURL = "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download"
}
exec := ansible.NewExecutor(ansible.ExecutorConfig{
WorkDir: workDir,
GRPCServerHost: "0.0.0.0", // TODO: make configurable
GRPCServerHost: grpcHost,
GRPCServerPort: grpcPort,
BackendURL: backendURL,
BackendURL: backendURL,
GiteaReleasesURL: giteaURL,
})
// Write playbooks on init
if err := exec.WriteAllPlaybooks(); err != nil {
// Log but don't fail - playbooks can be written later
_ = err
// Log the error - deployment will fail later if playbooks can't be written
fmt.Fprintf(os.Stderr, "WARNING: failed to write Ansible playbooks: %v\n", err)
}
return &AgentDeployGroup{
@@ -72,6 +83,48 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
return
}
// Validate auth credentials for each server
for i, server := range req.Servers {
switch server.AuthMethod {
case repository.AuthMethodKey:
if server.SSHKey == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("server %d (%s): sshKey is required when authMethod is 'key'", i, server.IP),
})
return
}
case repository.AuthMethodPassword:
if server.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("server %d (%s): password is required when authMethod is 'password'", i, server.IP),
})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("server %d (%s): invalid authMethod %q, expected 'key' or 'password'", i, server.IP, server.AuthMethod),
})
return
}
}
// Pre-flight check: verify community.docker collection is available for docker deployments
needsDockerCollection := false
for _, server := range req.Servers {
if server.DeployType == repository.DeployTypeDocker {
needsDockerCollection = true
break
}
}
if needsDockerCollection {
if err := adg.executor.CheckDockerCollection(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Docker deployment requires 'community.docker' Ansible collection: %v", err),
})
return
}
}
// Create work directory
workDir := adg.executor.WorkDir()
if err := os.MkdirAll(workDir, 0755); err != nil {
@@ -117,11 +170,14 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
Password: server.Password,
DeployType: string(server.DeployType),
Token: token,
GRPCURL: adg.executor.GRPCURL(),
},
}
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
// Rollback: delete the token we just created
_ = adg.Repo.DeleteRegistrationToken(token)
results = append(results, repository.DeployResult{
IP: server.IP,
AgentLabel: server.AgentLabel,
@@ -135,10 +191,14 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
// Run Ansible playbook for this server
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
// Clean up inventory file
os.Remove(inventoryPath)
// Clean up inventory file (log error but don't fail deployment)
if cleanupErr := os.Remove(inventoryPath); cleanupErr != nil {
fmt.Fprintf(os.Stderr, "WARNING: failed to remove inventory file %s: %v\n", inventoryPath, cleanupErr)
}
if err != nil {
// Rollback: delete the token since deployment failed
_ = adg.Repo.DeleteRegistrationToken(token)
results = append(results, repository.DeployResult{
IP: server.IP,
AgentLabel: server.AgentLabel,
@@ -154,6 +214,8 @@ func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
if len(deployResults) > 0 && !deployResults[0].Success {
success = false
errMsg = deployResults[0].Stderr
// Rollback: delete the token since ansible playbook reported failure
_ = adg.Repo.DeleteRegistrationToken(token)
}
results = append(results, repository.DeployResult{
+1 -1
View File
@@ -104,7 +104,7 @@ func (arg *AgentRegistrationGroup) Register(c *gin.Context) {
}
type RegisterRequest struct {
CSR string `json:"csr" binding:"required"`
CSR string `json:"csr" binding:"required"`
Token string `json:"token" binding:"required"`
}
+55 -6
View File
@@ -1,9 +1,11 @@
package handlers
import (
"fmt"
"net/http"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
"github.com/gin-gonic/gin"
"net/http"
)
type AgentsGroup struct {
@@ -15,17 +17,19 @@ func NewAgentsGroup(h *Handlers, coll *collector.Collector) AgentsGroup {
return AgentsGroup{Handlers: h, collector: coll}
}
// AgentInfo represents a connected agent's current status.
type AgentInfo struct {
Token string `json:"token"`
Label string `json:"label"`
Services []string `json:"services"`
ConnectedAt string `json:"connected_at"`
Token string `json:"token" example:"agent-001"` // Unique agent identifier
Label string `json:"label" example:"web-server-1"` // Human-readable label
Services []string `json:"services" example:"nginx:running,redis:up"` // List of services with status (format: "name:status")
ConnectedAt string `json:"connected_at" example:"2026-04-04 10:30:00"` // Time when agent connected (RFC3339-like)
}
// @Summary Get connected agents
// @Description Returns a list of all agents currently connected via Collector (log streaming)
// @Tags agents
// @Security Bearer
// @Accept json
// @Produce json
// @Success 200 {array} AgentInfo
// @Router /agents [get]
@@ -33,13 +37,58 @@ func (ag *AgentsGroup) List(c *gin.Context) {
agents := make([]AgentInfo, 0)
for _, agent := range ag.collector.Agents() {
services := make([]string, 0, len(agent.Services))
for _, s := range agent.Services {
services = append(services, fmt.Sprintf("%s:%s", s.Name, s.Status))
}
agents = append(agents, AgentInfo{
Token: agent.ID,
Label: agent.Label,
Services: agent.Services,
Services: services,
ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"),
})
}
c.JSON(http.StatusOK, agents)
}
// AgentSystemMetricsOut represents system metrics for a single agent.
type AgentSystemMetricsOut struct {
ID string `json:"id" example:"agent-001"`
Label string `json:"label" example:"web-server-1"`
ConnectedAt string `json:"connected_at" example:"2026-04-04 10:30:00"`
CPUPercent float64 `json:"cpu_percent" example:"45.2"`
MemoryPercent float64 `json:"memory_percent" example:"62.5"`
DiskPercent float64 `json:"disk_percent" example:"78.9"`
NetworkRxBytes float64 `json:"network_rx_bytes" example:"1048576.0"`
NetworkTxBytes float64 `json:"network_tx_bytes" example:"524288.0"`
}
// GetSystemMetrics returns system load metrics for all connected agents.
// @Summary Get agent system metrics
// @Description Returns CPU, RAM, disk, and network usage metrics for all connected agents
// @Tags agents
// @Security Bearer
// @Accept json
// @Produce json
// @Success 200 {array} AgentSystemMetricsOut
// @Router /agents/system-metrics [get]
func (ag *AgentsGroup) GetSystemMetrics(c *gin.Context) {
metricsMap := ag.collector.GetSystemMetrics()
metrics := make([]AgentSystemMetricsOut, 0, len(metricsMap))
for _, m := range metricsMap {
metrics = append(metrics, AgentSystemMetricsOut{
ID: m.ID,
Label: m.Label,
ConnectedAt: m.ConnectedAt.Format("2006-01-02 15:04:05"),
CPUPercent: m.CPUPercent,
MemoryPercent: m.MemoryPercent,
DiskPercent: m.DiskPercent,
NetworkRxBytes: m.NetworkRxBytes,
NetworkTxBytes: m.NetworkTxBytes,
})
}
c.JSON(http.StatusOK, metrics)
}
+47
View File
@@ -2,6 +2,8 @@ package handlers
import (
"errors"
"fmt"
"log"
"net/http"
"strings"
@@ -49,6 +51,39 @@ func (ag *AuthGroup) Login(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// RegisterUser registers a new user with all permissions set to false.
// @Summary Register user
// @Description Registers a new user with login, password, name, last name. All permissions are set to false.
// @Tags auth
// @Accept json
// @Param request body repository.UserRegister true "Registration data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /auth/register [post]
func (ag *AuthGroup) RegisterUser(c *gin.Context) {
var req repository.UserRegister
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
id, err := ag.Repo.RegisterUser(req)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
c.JSON(http.StatusConflict, gin.H{"error": "login already exists"})
return
}
log.Printf("[register] failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to register user: %v", err)})
return
}
log.Printf("[register] user registered: id=%s login=%s", id, req.Login)
c.JSON(http.StatusOK, gin.H{"message": "user registered"})
}
// CreateToken creates a new user.
// @Summary Create user
// @Description Creates a new user with permissions
@@ -59,6 +94,7 @@ func (ag *AuthGroup) Login(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/token [post]
func (ag *AuthGroup) CreateToken(c *gin.Context) {
var tc repository.TokenCreate
@@ -82,6 +118,7 @@ func (ag *AuthGroup) CreateToken(c *gin.Context) {
// @Produce json
// @Success 200 {object} repository.Tokens
// @Failure 401 {object} map[string]string
// @Security Bearer
// @Router /auth/validate [get]
func (ag *AuthGroup) ValidateToken(c *gin.Context) {
tokenVal, exists := c.Get(string(tokenContextKey))
@@ -106,6 +143,7 @@ func (ag *AuthGroup) ValidateToken(c *gin.Context) {
// @Produce json
// @Success 200 {array} repository.Tokens
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/tokens [get]
func (ag *AuthGroup) ListTokens(c *gin.Context) {
tokens, err := ag.Repo.ListTokens()
@@ -124,6 +162,7 @@ func (ag *AuthGroup) ListTokens(c *gin.Context) {
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/tokens/:login [delete]
func (ag *AuthGroup) DeleteToken(c *gin.Context) {
login := c.Param("login")
@@ -151,6 +190,7 @@ func (ag *AuthGroup) DeleteToken(c *gin.Context) {
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/token [delete]
func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
tokenVal, exists := c.Get(string(tokenContextKey))
@@ -182,6 +222,7 @@ func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/activate [post]
func (ag *AuthGroup) ActivateUser(c *gin.Context) {
login := c.Param("login")
@@ -211,6 +252,7 @@ func (ag *AuthGroup) ActivateUser(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/deactivate [post]
func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
login := c.Param("login")
@@ -238,6 +280,7 @@ func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
// @Produce json
// @Success 200 {array} repository.Tokens
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/inactive [get]
func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
tokens, err := ag.Repo.ListInactiveTokens()
@@ -258,6 +301,7 @@ func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login [get]
func (ag *AuthGroup) GetUser(c *gin.Context) {
login := c.Param("login")
@@ -290,6 +334,7 @@ func (ag *AuthGroup) GetUser(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login [put]
func (ag *AuthGroup) UpdateUser(c *gin.Context) {
login := c.Param("login")
@@ -327,6 +372,7 @@ func (ag *AuthGroup) UpdateUser(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/permissions [put]
func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
login := c.Param("login")
@@ -364,6 +410,7 @@ func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /auth/users/:login/password [put]
func (ag *AuthGroup) ResetUserPassword(c *gin.Context) {
login := c.Param("login")
+35
View File
@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func CorsMiddleware(origincfg string) gin.HandlerFunc {
origins := strings.Split(origincfg, ";")
if origins[0] == "" {
panic("zero cors origins wtf is your config")
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if !lo.Contains(origins, origin) {
origin = origins[0]
}
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
// c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().
Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, Authorization")
c.Writer.Header().
Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PATCH, DELETE, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+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"
}
+213 -43
View File
@@ -1,67 +1,237 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
"github.com/gin-gonic/gin"
)
type JobsHandlers struct {
cmder *commander.Commander
tracker *commander.ConnTracker
svc *service.ScriptService
whereami string
jobRepo *repository.JobRepository
}
func NewJobsHandlers(cmder *commander.Commander) JobsHandlers {
return JobsHandlers{cmder}
func NewJobsHandlers(tracker *commander.ConnTracker, svc *service.ScriptService, whereami string, jobRepo *repository.JobRepository) JobsHandlers {
return JobsHandlers{tracker: tracker, svc: svc, whereami: whereami, jobRepo: jobRepo}
}
func (self *JobsHandlers) AddJob(c *gin.Context) {
err := func() error {
type In struct {
Command []string `json:"command"`
Stdin *string `json:"stdin"`
AID string `json:"agent_id"`
// AddJobIn is the request body for creating a job.
type AddJobIn struct {
Command string `json:"command" binding:"required"`
InterpreterID int64 `json:"interpreter_id"`
Stdin *string `json:"stdin"`
AgentID string `json:"agent_id" binding:"required"`
}
// AddJobOut is the response body for a submitted job.
type AddJobOut struct {
ID int64 `json:"id"`
Command []string `json:"command"`
WaitURL string `json:"wait_url"`
}
// JobResult is the response body for a completed job.
type JobResult struct {
ID int64 `json:"id"`
Command []string `json:"command"`
Stdin *string `json:"stdin"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int32 `json:"status"`
}
// AddJob submits a job to an agent and returns a wait_url for the result.
// @Summary Submit a job to an agent
// @Description Sends a command to the specified agent and returns a URL to wait for the result
// @Tags jobs
// @Accept json
// @Produce json
// @Param body body AddJobIn true "Job request"
// @Success 201 {object} AddJobOut
// @Router /jobs [post]
func (h *JobsHandlers) AddJob(c *gin.Context) {
var in AddJobIn
if err := c.Bind(&in); err != nil {
c.Error(err)
return
}
result, err := h.runCommand(c, in.AgentID, in.InterpreterID, in.Command, in.Stdin)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, result)
}
// runCommand resolves command, submits a job to the agent, and returns AddJobOut.
// Shared between jobs and scripts handlers.
func (h *JobsHandlers) runCommand(
c *gin.Context,
agentID string,
interpID int64,
command string,
stdin *string,
) (*AddJobOut, error) {
agent, ok := h.tracker.GetAgent(agentID)
if !ok {
return nil, fmt.Errorf("agent not found")
}
cmd, err := resolveCommand(c, h.svc, interpID, command)
if err != nil {
return nil, err
}
jid, err := agent.AddJob(models.JobForInsert{
Command: cmd,
Stdin: stdin,
})
if err != nil {
return nil, err
}
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", h.whereami, jid)
return &AddJobOut{
ID: jid,
Command: cmd,
WaitURL: waitURL,
}, nil
}
// WaitJob waits for a submitted job to complete (long-poll).
// First checks the database; if already finished, returns immediately.
// Otherwise waits on the agent for the result.
// @Summary Wait for job result
// @Description Long-polls for a job result. Returns immediately if the job is already finished.
// @Tags jobs
// @Accept json
// @Produce json
// @Param id path int true "Job ID"
// @Success 200 {object} JobResult
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /jobs/{id}/wait [post]
func (h *JobsHandlers) WaitJob(c *gin.Context) {
jid, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job id"})
return
}
// Check database first
job, err := h.jobRepo.GetJobByID(c.Request.Context(), jid)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
return
}
var in In
if err := c.Bind(&in); err != nil {
return err
}
agent, ok := self.cmder.GetAgent(in.AID)
if !ok {
c.Status(404)
return fmt.Errorf("Agent not found")
}
jid, err := agent.AddJob(models.JobForInsert{
Command: in.Command,
Stdin: in.Stdin,
})
if err != nil {
return err
}
job, err := agent.WaitJob(jid)
if err != nil {
return err
}
type Out struct {
ID int64 `json:"id"`
Command []string `json:"command"`
Stdin *string `json:"stdin"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int32 `json:"status"`
}
c.JSON(201, Out{
c.Error(err)
return
}
// If job is already completed (has output or non-zero status), return immediately
if job.Status != nil || job.Stdout != nil || job.Stderr != nil {
c.JSON(http.StatusOK, JobResult{
ID: job.ID,
Command: job.Command,
Stdin: job.Stdin,
Stdout: job.Stdout,
Stderr: job.Stderr,
Status: job.Status,
Stdout: *job.Stdout,
Stderr: *job.Stderr,
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 {
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,
})
}
+9 -9
View File
@@ -20,10 +20,10 @@ func NewLogHandlers(logRepo *repository.LogRepository) *LogHandlers {
type InsertLogRequest struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level" binding:"required"`
Service string `json:"service" binding:"required"`
Agent string `json:"agent" binding:"required"`
Message string `json:"message" binding:"required"`
Level string `json:"level" binding:"required"`
Service string `json:"service" binding:"required"`
Agent string `json:"agent" binding:"required"`
Message string `json:"message" binding:"required"`
}
// @Summary Insert log entry
@@ -105,13 +105,13 @@ func (lh *LogHandlers) InsertBatch(c *gin.Context) {
}
type SearchLogsRequest struct {
Level string `form:"level"`
Service string `form:"service"`
Agent string `form:"agent"`
Level string `form:"level"`
Service string `form:"service"`
Agent string `form:"agent"`
DateFrom string `form:"date_from"`
DateTo string `form:"date_to"`
Limit int `form:"limit"`
Offset int `form:"offset"`
Limit int `form:"limit"`
Offset int `form:"offset"`
}
// @Summary Search logs
+198
View File
@@ -0,0 +1,198 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
"github.com/gin-gonic/gin"
)
type ScriptHandlers struct {
svc *service.ScriptService
tracker *commander.ConnTracker
whereami string
}
func NewScriptHandlers(svc *service.ScriptService, tracker *commander.ConnTracker, whereami string) ScriptHandlers {
return ScriptHandlers{svc: svc, tracker: tracker, whereami: whereami}
}
type RunScriptIn struct {
AgentID string `json:"agent_id" binding:"required"`
InterpreterID int64 `json:"interpreter_id" binding:"required"`
ScriptText string `json:"script_text" binding:"required"`
Stdin *string `json:"stdin"`
}
// RunScript submits a script as a job and returns a wait_url for the result.
// @Summary Run a script on an agent
// @Description Resolves interpreter argv[] and sends the full command to the agent
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body RunScriptIn true "Script request"
// @Success 201 {object} AddJobOut
// @Security Bearer
// @Router /scripts/run [post]
func (h *ScriptHandlers) RunScript(c *gin.Context) {
var in RunScriptIn
if err := c.Bind(&in); err != nil {
c.Error(err)
return
}
agent, ok := h.tracker.GetAgent(in.AgentID)
if !ok {
c.Status(http.StatusNotFound)
c.Error(fmt.Errorf("agent not found"))
return
}
command, err := h.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText)
if err != nil {
c.Error(err)
return
}
jid, err := agent.AddJob(models.JobForInsert{
Command: command,
Stdin: in.Stdin,
})
if err != nil {
c.Error(err)
return
}
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", h.whereami, jid)
c.JSON(http.StatusCreated, AddJobOut{
ID: jid,
Command: command,
WaitURL: waitURL,
})
}
// ListInterpreters returns all registered script interpreters.
// @Summary List interpreters
// @Description Returns all script interpreters available in the system
// @Tags scripts
// @Produce json
// @Success 200 {array} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters [get]
func (h *ScriptHandlers) ListInterpreters(c *gin.Context) {
interpreters, err := h.svc.List(c.Request.Context())
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, interpreters)
}
// CreateInterpreter registers a new script interpreter.
// @Summary Create interpreter
// @Description Registers a new script interpreter with name, label, and argv
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body repository.ScriptInterpreterCreate true "Interpreter definition"
// @Success 201 {object} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters [post]
func (h *ScriptHandlers) CreateInterpreter(c *gin.Context) {
var in repository.ScriptInterpreterCreate
if err := c.BindJSON(&in); err != nil {
c.Error(err)
return
}
si, err := h.svc.Create(c.Request.Context(), in)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, si)
}
// GetInterpreter returns a single interpreter by ID.
// @Summary Get interpreter
// @Description Returns a script interpreter by ID
// @Tags scripts
// @Produce json
// @Param id path int true "Interpreter ID"
// @Success 200 {object} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters/:id [get]
func (h *ScriptHandlers) GetInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.Error(err)
return
}
si, err := h.svc.GetByID(c.Request.Context(), id)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, si)
}
// UpdateInterpreter updates an interpreter.
// @Summary Update interpreter
// @Description Updates fields of a script interpreter
// @Tags scripts
// @Accept json
// @Produce json
// @Param id path int true "Interpreter ID"
// @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields"
// @Success 200 {object} repository.ScriptInterpreter
// @Security Bearer
// @Router /scripts/interpreters/:id [put]
func (h *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.Error(err)
return
}
var in repository.ScriptInterpreterUpdate
if err := c.BindJSON(&in); err != nil {
c.Error(err)
return
}
si, err := h.svc.Update(c.Request.Context(), id, in)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, si)
}
// DeleteInterpreter removes an interpreter.
// @Summary Delete interpreter
// @Description Removes a script interpreter by ID
// @Tags scripts
// @Param id path int true "Interpreter ID"
// @Success 204
// @Security Bearer
// @Router /scripts/interpreters/:id [delete]
func (h *ScriptHandlers) DeleteInterpreter(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.Error(err)
return
}
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
+528
View File
@@ -0,0 +1,528 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
"github.com/gin-gonic/gin"
)
// ScriptHandlersGroup handles script management routes.
type ScriptHandlersGroup struct {
svc *service.ScriptService
cmder *commander.Commander
whereami string
}
// NewScriptHandlersGroup creates a new ScriptHandlersGroup.
func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander, whereami string) *ScriptHandlersGroup {
return &ScriptHandlersGroup{svc: svc, cmder: cmder, whereami: whereami}
}
// GetTree returns the script directory tree.
// @Summary Get script directory tree
// @Description Returns a hierarchical tree of all scripts organized by their paths
// @Tags scripts
// @Produce json
// @Success 200 {array} repository.ScriptTreeNode
// @Security Bearer
// @Router /scripts/tree [get]
func (sh *ScriptHandlersGroup) GetTree(c *gin.Context) {
tree, err := sh.svc.BuildTree()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build script tree"})
return
}
if tree == nil {
tree = []repository.ScriptTreeNode{}
}
c.JSON(http.StatusOK, tree)
}
// CreateScript creates a new script.
// @Summary Create script
// @Description Creates a new script with path, content, and interpreter binding
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body repository.ScriptCreate true "Script data"
// @Success 201 {object} repository.Script
// @Security Bearer
// @Router /scripts [post]
func (sh *ScriptHandlersGroup) CreateScript(c *gin.Context) {
var req repository.ScriptCreate
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate path
if err := validateScriptPath(req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
script, err := sh.svc.Repo.CreateScript(req)
if err != nil {
if isUniqueConstraint(err) {
c.JSON(http.StatusConflict, gin.H{"error": "script with this path already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create script"})
return
}
c.JSON(http.StatusCreated, script)
}
// GetScript returns a script by ID.
// @Summary Get script
// @Description Returns a script by its ID
// @Tags scripts
// @Produce json
// @Param id path int true "Script ID"
// @Success 200 {object} repository.Script
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Security Bearer
// @Router /scripts/:id [get]
func (sh *ScriptHandlersGroup) GetScript(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
script, err := sh.svc.Repo.GetScript(id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"})
return
}
c.JSON(http.StatusOK, script)
}
// UpdateScript updates a script.
// @Summary Update script
// @Description Updates a script's path, content, or interpreter
// @Tags scripts
// @Accept json
// @Produce json
// @Param id path int true "Script ID"
// @Param body body repository.ScriptUpdate true "Script data"
// @Success 200 {object} repository.Script
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Security Bearer
// @Router /scripts/:id [put]
func (sh *ScriptHandlersGroup) UpdateScript(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req repository.ScriptUpdate
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate path if it's being updated
if req.Path != nil {
if err := validateScriptPath(*req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
script, err := sh.svc.Repo.UpdateScript(id, req)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update script"})
return
}
c.JSON(http.StatusOK, script)
}
// DeleteScript deletes a script.
// @Summary Delete script
// @Description Deletes a script by its ID
// @Tags scripts
// @Param id path int true "Script ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Security Bearer
// @Router /scripts/:id [delete]
func (sh *ScriptHandlersGroup) DeleteScript(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := sh.svc.Repo.DeleteScript(id); err != nil {
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete script"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "script deleted"})
}
// RunScriptByID executes a stored script on a target agent.
// @Summary Run script by ID
// @Description Loads a script from storage, resolves interpreter command, and submits it to the agent
// @Tags scripts
// @Accept json
// @Produce json
// @Param id path int true "Script ID"
// @Param body body RunStoredScriptIn true "Agent ID and optional stdin"
// @Success 201 {object} AddJobOut
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Security Bearer
// @Router /scripts/:id/run [post]
func (sh *ScriptHandlersGroup) RunScriptByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var in RunStoredScriptIn
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
script, err := sh.svc.Repo.GetScript(id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"})
return
}
command, err := sh.svc.ResolveCommand(c.Request.Context(), script.InterpreterID, script.Content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve command: %v", err)})
return
}
agent, ok := sh.cmder.GetAgent(in.Token)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
jid, err := agent.AddJob(models.JobForInsert{
Command: command,
Stdin: in.Stdin,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add job: %v", err)})
return
}
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", sh.whereami, jid)
c.JSON(http.StatusCreated, AddJobOut{
ID: jid,
Command: command,
WaitURL: waitURL,
})
}
// RunStoredScriptIn is the request body for running a stored script on an agent.
type RunStoredScriptIn struct {
Token string `json:"token" binding:"required"`
Stdin *string `json:"stdin"`
}
// CreateFolderRequest is the request body for creating a script folder.
type CreateFolderRequest struct {
Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path (e.g. 'deploy/nginx')"`
}
// DeleteFolderRequest is the request body for deleting a script folder.
type DeleteFolderRequest struct {
Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path to delete"`
}
// RenameRequest is the request body for renaming a script or folder.
type RenameRequest struct {
OldPath string `json:"old_path" binding:"required" example:"deploy/nginx" description:"Current path"`
NewPath string `json:"new_path" binding:"required" example:"deploy/nginx-v2" description:"New path"`
}
// Rename renames a script or all scripts under a folder path.
// @Summary Rename script or folder
// @Description Renames a single script or all scripts under a folder prefix
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body RenameRequest true "Rename request"
// @Success 200 {object} map[string]interface{} "Rename result with count of renamed scripts"
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Security Bearer
// @Router /scripts/rename [post]
func (sh *ScriptHandlersGroup) Rename(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate new path
if err := validateScriptPath(req.NewPath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid new path: %v", err)})
return
}
// Validate old path
if err := validateScriptPath(req.OldPath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid old path: %v", err)})
return
}
// Get all scripts
allScripts, err := sh.svc.Repo.ListScripts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"})
return
}
// Find scripts to rename: exact match or folder prefix
prefix := req.OldPath + "/"
var toRename []repository.Script
for _, script := range allScripts {
if script.Path == req.OldPath || strings.HasPrefix(script.Path, prefix) {
toRename = append(toRename, script)
}
}
if len(toRename) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no scripts found with this path"})
return
}
// Rename each script
renamedCount := 0
for _, script := range toRename {
newPath := req.NewPath + strings.TrimPrefix(script.Path, req.OldPath)
// Check if new path already exists (excluding the scripts we're renaming)
for _, existing := range allScripts {
if existing.ID != script.ID && existing.Path == newPath {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("path '%s' already exists", newPath)})
return
}
}
_, err := sh.svc.Repo.UpdateScript(script.ID, repository.ScriptUpdate{
Path: &newPath,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to rename %s: %v", script.Path, err)})
return
}
renamedCount++
}
c.JSON(http.StatusOK, gin.H{
"message": "renamed",
"old_path": req.OldPath,
"new_path": req.NewPath,
"renamed_count": renamedCount,
})
}
// CreateFolder creates a virtual folder in the script tree.
// @Summary Create folder
// @Description Creates a virtual folder by creating a placeholder script with the folder path
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body CreateFolderRequest true "Folder path"
// @Success 201 {object} map[string]string "Folder created"
// @Security Bearer
// @Router /scripts/folder [post]
func (sh *ScriptHandlersGroup) CreateFolder(c *gin.Context) {
var req CreateFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate folder path
if err := validateScriptPath(req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)})
return
}
// Create a placeholder script with the folder path to ensure the folder exists in the tree
// The placeholder uses ".folder" as content and interpreter_id 0 (will be resolved at runtime)
_, err := sh.svc.Repo.CreateScript(repository.ScriptCreate{
Path: req.Path,
Content: "",
InterpreterID: 0,
})
if err != nil {
if isUniqueConstraint(err) {
c.JSON(http.StatusConflict, gin.H{"error": "folder with this path already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "folder created", "path": req.Path})
}
// DeleteFolder deletes all scripts under a given path prefix.
// @Summary Delete folder
// @Description Deletes all scripts that start with the given folder path
// @Tags scripts
// @Accept json
// @Produce json
// @Param body body DeleteFolderRequest true "Folder path"
// @Success 200 {object} map[string]interface{} "Folder deleted with count of deleted scripts"
// @Security Bearer
// @Router /scripts/folder [delete]
func (sh *ScriptHandlersGroup) DeleteFolder(c *gin.Context) {
var req DeleteFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Validate folder path
if err := validateScriptPath(req.Path); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)})
return
}
// Get all scripts and filter by path prefix
allScripts, err := sh.svc.Repo.ListScripts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"})
return
}
prefix := req.Path + "/"
deletedCount := 0
for _, script := range allScripts {
// Delete scripts that are in this folder (path starts with prefix)
// or the folder placeholder itself (exact match)
if script.Path == req.Path || strings.HasPrefix(script.Path, prefix) {
if err := sh.svc.Repo.DeleteScript(script.ID); err != nil && !errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete script %s: %v", script.Path, err)})
return
}
deletedCount++
}
}
c.JSON(http.StatusOK, gin.H{"message": "folder deleted", "path": req.Path, "deleted_count": deletedCount})
}
// GetScriptByPath returns a script by its path.
// @Summary Get script by path
// @Description Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')
// @Tags scripts
// @Produce json
// @Param path query string true "Script path"
// @Success 200 {object} repository.Script
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Security Bearer
// @Router /scripts/by-path [get]
func (sh *ScriptHandlersGroup) GetScriptByPath(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"})
return
}
script, err := sh.svc.Repo.GetScriptByPath(path)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "script not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"})
return
}
c.JSON(http.StatusOK, script)
}
// isUniqueConstraint checks if the error is a SQLite UNIQUE constraint violation.
func isUniqueConstraint(err error) bool {
return err != nil && (err.Error() != "" && contains(err.Error(), "UNIQUE constraint"))
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// validateScriptPath validates that a script path is well-formed.
// Rules: non-empty, no leading slash, no double slashes, no trailing slash, no empty segments.
func validateScriptPath(path string) error {
if path == "" {
return fmt.Errorf("path cannot be empty")
}
if strings.HasPrefix(path, "/") {
return fmt.Errorf("path cannot start with '/'")
}
if strings.HasSuffix(path, "/") {
return fmt.Errorf("path cannot end with '/'")
}
if strings.Contains(path, "//") {
return fmt.Errorf("path cannot contain '//'")
}
// Check for empty segments (e.g. "a//b" already caught, but "a/ /b" should be allowed)
segments := strings.Split(path, "/")
for i, seg := range segments {
if strings.TrimSpace(seg) == "" {
return fmt.Errorf("path segment %d cannot be empty or whitespace", i+1)
}
}
return nil
}
+10 -5
View File
@@ -1,10 +1,8 @@
package models
type Job struct {
ID int64
JobForInsert
JobForUpdate
type JobBase struct {
ID int64
AgentID string
}
type JobForInsert struct {
Command []string
@@ -15,3 +13,10 @@ type JobForUpdate struct {
Stderr string
Status int32
}
type Job struct {
JobBase
JobForInsert
Stdout *string
Stderr *string
Status *int32
}
+56 -10
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
@@ -23,7 +24,11 @@ func (r *JobRepository) Init(ctx context.Context) error {
return err
}
func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error) {
func (r *JobRepository) InitJob(
ctx context.Context,
agentID string,
job models.JobForInsert,
) (int64, error) {
commandJSON, err := json.Marshal(job.Command)
if err != nil {
return 0, fmt.Errorf("marshal command: %w", err)
@@ -34,9 +39,12 @@ func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.
stdinVal = job.Stdin
}
result, err := r.DB.ExecContext(ctx,
result, err := r.DB.ExecContext(
ctx,
`INSERT INTO jobs (agent_id, command, stdin, stdout, stderr, status) VALUES (?, ?, ?, '', '', 0)`,
agentID, string(commandJSON), stdinVal,
agentID,
string(commandJSON),
stdinVal,
)
if err != nil {
return 0, err
@@ -45,10 +53,18 @@ func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.
return result.LastInsertId()
}
func (r *JobRepository) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) {
result, err := r.DB.ExecContext(ctx,
func (r *JobRepository) UpdateJobInDB(
ctx context.Context,
jid int64,
msg models.JobForUpdate,
) (models.Job, error) {
result, err := r.DB.ExecContext(
ctx,
`UPDATE jobs SET stdout = ?, stderr = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
msg.Stdout, msg.Stderr, msg.Status, jid,
msg.Stdout,
msg.Stderr,
msg.Status,
jid,
)
if err != nil {
return models.Job{}, err
@@ -71,9 +87,9 @@ func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job,
var stdinVal *string
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,
).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 == sql.ErrNoRows {
return models.Job{}, ErrNotFound
@@ -81,10 +97,40 @@ func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job,
return models.Job{}, err
}
if err := json.Unmarshal([]byte(commandJSON), &job.JobForInsert.Command); err != nil {
if err := json.Unmarshal([]byte(commandJSON), &job.Command); err != nil {
return models.Job{}, fmt.Errorf("unmarshal command: %w", err)
}
job.JobForInsert.Stdin = stdinVal
job.Stdin = stdinVal
return job, nil
}
type JobMetrics struct {
Total int
Success int
Failed int
Pending int
}
// GetJobMetrics returns job success metrics for jobs updated since the given time.
// If agentID is non-empty, results are filtered to that agent only.
func (r *JobRepository) GetJobMetrics(ctx context.Context, since time.Time, agentID string) (JobMetrics, error) {
var m JobMetrics
query := `SELECT
COUNT(*),
SUM(CASE WHEN status = 0 AND (stdout != '' OR stderr != '') THEN 1 ELSE 0 END),
SUM(CASE WHEN status != 0 THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 0 AND stdout = '' AND stderr = '' THEN 1 ELSE 0 END)
FROM jobs WHERE updated_at >= ?`
args := []any{since}
if agentID != "" {
query += " AND agent_id = ?"
args = append(args, agentID)
}
err := r.DB.QueryRowContext(ctx, query, args...).Scan(&m.Total, &m.Success, &m.Failed, &m.Pending)
if err != nil {
return JobMetrics{}, err
}
return m, nil
}
+12 -6
View File
@@ -84,13 +84,13 @@ func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry
}
type LogFilter struct {
Level string
Service string
Agent string
Level string
Service string
Agent string
DateFrom time.Time
DateTo time.Time
Limit int
Offset int
Limit int
Offset int
}
func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage.LogEntry, error) {
@@ -157,7 +157,13 @@ func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage
logs := make([]storage.LogEntry, 0)
for rows.Next() {
var log storage.LogEntry
if err := rows.Scan(&log.Timestamp, &log.Level, &log.Service, &log.Agent, &log.Message); err != nil {
if err := rows.Scan(
&log.Timestamp,
&log.Level,
&log.Service,
&log.Agent,
&log.Message,
); err != nil {
return nil, err
}
logs = append(logs, log)
+74 -32
View File
@@ -2,29 +2,37 @@ package repository
// Tokens represents a user record with info and permissions.
type Tokens struct {
ID int64 `json:"id"`
Name string `json:"name"`
LastName string `json:"last_name"`
Login string `json:"login"`
Token string `json:"token"`
PermissionView bool `json:"permission_view"`
PermissionManage bool `json:"permission_manage_agent"`
PermissionAdmin bool `json:"permission_admin"`
IsActive bool `json:"is_active"`
}
// TokenCreate is the request body for creating a new user.
type TokenCreate struct {
Name string `json:"name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
ID int64 `json:"id"`
Name string `json:"name"`
LastName string `json:"last_name"`
Login string `json:"login"`
Token string `json:"token"`
PermissionView bool `json:"permission_view"`
PermissionManage bool `json:"permission_manage_agent"`
PermissionAdmin bool `json:"permission_admin"`
IsActive bool `json:"is_active"`
}
// TokenCreate is the request body for creating a new user.
type TokenCreate struct {
Name string `json:"name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
PermissionView bool `json:"permission_view"`
PermissionManage bool `json:"permission_manage_agent"`
PermissionAdmin bool `json:"permission_admin"`
IsActive bool `json:"is_active"`
}
// UserRegister is the request body for public user registration (all permissions false).
type UserRegister struct {
Name string `json:"name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
}
// TokenUpdate is the request body for updating an existing user.
type TokenUpdate struct {
Name string `json:"name"`
@@ -51,7 +59,7 @@ type BatchActionRequest struct {
// LoginRequest is the request body for login.
type LoginRequest struct {
Login string `json:"login" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
}
@@ -109,14 +117,14 @@ const (
// AgentDeployConfig represents the configuration for deploying an agent to a server
// @Description Configuration for deploying HellreigN agent to a single server
type AgentDeployConfig struct {
User string `json:"user" binding:"required" example:"admin" description:"SSH username"`
IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"`
Port int `json:"port" example:"22" description:"SSH port (default: 22)"`
AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"`
SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"`
Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"`
DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"`
AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"`
User string `json:"user" binding:"required" example:"admin" description:"SSH username"`
IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"`
Port int `json:"port" example:"22" description:"SSH port (default: 22)"`
AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"`
SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"`
Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"`
DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"`
AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"`
}
// DeployAgentsRequest represents the request body for deploying agents to multiple servers
@@ -129,15 +137,49 @@ type DeployAgentsRequest struct {
// @Description Response containing deployment results and registration tokens
type DeployResponse struct {
Message string `json:"message" example:"Deployment completed"`
Results []DeployResult `json:"results" description:"Deployment results for each server"`
Results []DeployResult `json:"results" description:"Deployment results for each server"`
}
// DeployResult represents the result of deploying to a single server
// @Description Result of deploying to a single server
type DeployResult struct {
IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"`
AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"`
Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"`
Success bool `json:"success" example:"true" description:"Whether deployment succeeded"`
Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"`
IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"`
AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"`
Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"`
Success bool `json:"success" example:"true" description:"Whether deployment succeeded"`
Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"`
}
// Script represents a stored script with path and interpreter binding.
type Script struct {
ID int64 `json:"id"`
Path string `json:"path"`
Content string `json:"content"`
InterpreterID int64 `json:"interpreter_id"`
CreatedAt *string `json:"created_at"`
UpdatedAt *string `json:"updated_at"`
}
// ScriptCreate is the request body for creating a script.
type ScriptCreate struct {
Path string `json:"path" binding:"required"`
Content string `json:"content"`
InterpreterID int64 `json:"interpreter_id" binding:"required"`
}
// ScriptUpdate is the request body for updating a script.
type ScriptUpdate struct {
Path *string `json:"path"`
Content *string `json:"content"`
InterpreterID *int64 `json:"interpreter_id"`
}
// ScriptTreeNode represents a node in the script directory tree.
type ScriptTreeNode struct {
Name string `json:"name"`
Type string `json:"type"` // "folder" or "file"
Children []ScriptTreeNode `json:"children,omitempty"`
ID *int64 `json:"id,omitempty"`
Content *string `json:"content,omitempty"`
InterpreterID *int64 `json:"interpreter_id,omitempty"`
}
+193 -9
View File
@@ -3,6 +3,8 @@ package repository
import (
"database/sql"
"errors"
"fmt"
"log"
"strconv"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
@@ -50,8 +52,15 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
result, err := r.DB.Exec(
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tc.Name, tc.LastName, tc.Login, string(hashed), token,
tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin, tc.IsActive,
tc.Name,
tc.LastName,
tc.Login,
string(hashed),
token,
tc.PermissionView,
tc.PermissionManage,
tc.PermissionAdmin,
tc.IsActive,
)
if err != nil {
return "", err
@@ -64,6 +73,39 @@ func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
return strconv.FormatInt(id, 10), nil
}
// RegisterUser inserts a new user with all permissions set to false and is_active=false.
func (r *Repository) RegisterUser(ur UserRegister) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(ur.Password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
token, err := utils.RandomToken()
if err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
result, err := r.DB.Exec(
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0)`,
ur.Name,
ur.LastName,
ur.Login,
string(hashed),
token,
)
if err != nil {
return "", fmt.Errorf("insert user: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return "", fmt.Errorf("get last insert id: %w", err)
}
log.Printf("[register] user created: id=%s login=%s", strconv.FormatInt(id, 10), ur.Login)
return strconv.FormatInt(id, 10), nil
}
// Login authenticates by login/password, generates a new token, and returns LoginResponse.
func (r *Repository) Login(login, password string) (*LoginResponse, error) {
var t Tokens
@@ -118,11 +160,11 @@ func (r *Repository) Login(login, password string) (*LoginResponse, error) {
func (r *Repository) GetToken(token string) (*Tokens, error) {
var t Tokens
err := r.DB.QueryRow(
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
FROM tokens WHERE token = ?`,
token,
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin)
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -136,7 +178,7 @@ func (r *Repository) GetToken(token string) (*Tokens, error) {
// ListTokens returns all users without password and token.
func (r *Repository) ListTokens() ([]Tokens, error) {
rows, err := r.DB.Query(
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin, is_active
FROM tokens`,
)
if err != nil {
@@ -148,7 +190,7 @@ func (r *Repository) ListTokens() ([]Tokens, error) {
for rows.Next() {
var t Tokens
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login,
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin); err != nil {
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive); err != nil {
return nil, err
}
tokens = append(tokens, t)
@@ -257,6 +299,12 @@ func (r *Repository) MarkRegistrationTokenUsed(token string) error {
return nil
}
// DeleteRegistrationToken deletes a registration token (used for rollback on deployment failure).
func (r *Repository) DeleteRegistrationToken(token string) error {
_, err := r.DB.Exec(`DELETE FROM registration_tokens WHERE token = ?`, token)
return err
}
// ActivateToken activates a user by token value.
func (r *Repository) ActivateToken(token string) error {
result, err := r.DB.Exec(
@@ -302,12 +350,13 @@ func (r *Repository) ActivateUserByLogin(login string) error {
login,
)
if err != nil {
return err
return fmt.Errorf("activate exec: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return err
return fmt.Errorf("rows affected: %w", err)
}
log.Printf("[activate] login=%s affected=%d", login, affected)
if affected == 0 {
return ErrNotFound
}
@@ -422,7 +471,11 @@ func (r *Repository) UpdatePermissions(login string, update TokenUpdatePermissio
result, err := r.DB.Exec(
`UPDATE tokens SET permission_view = ?, permission_manage_agent = ?, permission_admin = ?, is_active = ? WHERE login = ?`,
newView, newManage, newAdmin, newActive, login,
newView,
newManage,
newAdmin,
newActive,
login,
)
if err != nil {
return err
@@ -460,3 +513,134 @@ func (r *Repository) UpdatePassword(login string, newPassword string) error {
}
return nil
}
// CreateScript inserts a new script into the database.
func (r *Repository) CreateScript(sc ScriptCreate) (*Script, error) {
result, err := r.DB.Exec(
`INSERT INTO scripts (path, content, interpreter_id) VALUES (?, ?, ?)`,
sc.Path, sc.Content, sc.InterpreterID,
)
if err != nil {
return nil, fmt.Errorf("insert script: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get last insert id: %w", err)
}
return &Script{
ID: id,
Path: sc.Path,
Content: sc.Content,
InterpreterID: sc.InterpreterID,
}, nil
}
// GetScript retrieves a script by ID.
func (r *Repository) GetScript(id int64) (*Script, error) {
var s Script
err := r.DB.QueryRow(
`SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts WHERE id = ?`,
id,
).Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
}
// GetScriptByPath retrieves a script by its path.
func (r *Repository) GetScriptByPath(path string) (*Script, error) {
var s Script
err := r.DB.QueryRow(
`SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts WHERE path = ?`,
path,
).Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &s, nil
}
// ListScripts returns all scripts.
func (r *Repository) ListScripts() ([]Script, error) {
rows, err := r.DB.Query(
`SELECT id, path, content, interpreter_id, created_at, updated_at FROM scripts`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var scripts []Script
for rows.Next() {
var s Script
if err := rows.Scan(&s.ID, &s.Path, &s.Content, &s.InterpreterID, &s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
scripts = append(scripts, s)
}
return scripts, rows.Err()
}
// UpdateScript updates a script by ID.
func (r *Repository) UpdateScript(id int64, update ScriptUpdate) (*Script, error) {
existing, err := r.GetScript(id)
if err != nil {
return nil, err
}
newPath := existing.Path
newContent := existing.Content
newInterpreterID := existing.InterpreterID
if update.Path != nil {
newPath = *update.Path
}
if update.Content != nil {
newContent = *update.Content
}
if update.InterpreterID != nil {
newInterpreterID = *update.InterpreterID
}
_, err = r.DB.Exec(
`UPDATE scripts SET path = ?, content = ?, interpreter_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
newPath, newContent, newInterpreterID, id,
)
if err != nil {
return nil, fmt.Errorf("update script: %w", err)
}
return &Script{
ID: id,
Path: newPath,
Content: newContent,
InterpreterID: newInterpreterID,
}, nil
}
// DeleteScript deletes a script by ID.
func (r *Repository) DeleteScript(id int64) error {
result, err := r.DB.Exec(`DELETE FROM scripts WHERE id = ?`, id)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
@@ -0,0 +1,204 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
)
type ScriptInterpreter struct {
ID int64 `json:"id"`
Name string `json:"name"`
Label string `json:"label"`
Argv []string `json:"argv"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ScriptInterpreterCreate struct {
Name string `json:"name" binding:"required"`
Label string `json:"label" binding:"required"`
Argv []string `json:"argv" binding:"required"`
}
type ScriptInterpreterUpdate struct {
Name *string `json:"name"`
Label *string `json:"label"`
Argv []string `json:"argv"`
}
type ScriptInterpreterRepo struct {
DB *sql.DB
}
func NewScriptInterpreterRepo(db *sql.DB) *ScriptInterpreterRepo {
return &ScriptInterpreterRepo{DB: db}
}
func (r *ScriptInterpreterRepo) Init(ctx context.Context) error {
_, err := r.DB.ExecContext(ctx, storage.CreateScriptInterpretersTable)
return err
}
func (r *ScriptInterpreterRepo) Create(
ctx context.Context,
in ScriptInterpreterCreate,
) (*ScriptInterpreter, error) {
argvJSON, err := json.Marshal(in.Argv)
if err != nil {
return nil, err
}
result, err := r.DB.ExecContext(ctx,
`INSERT INTO script_interpreters (name, label, argv) VALUES (?, ?, ?)`,
in.Name, in.Label, string(argvJSON),
)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return r.GetByID(ctx, id)
}
func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptInterpreter, error) {
var si ScriptInterpreter
var argvJSON string
var createdAt, updatedAt string
err := r.DB.QueryRowContext(
ctx,
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters WHERE id = ?`,
id,
).Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
return nil, err
}
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return &si, nil
}
func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]ScriptInterpreter, error) {
rows, err := r.DB.QueryContext(ctx,
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var interpreters []ScriptInterpreter
for rows.Next() {
var si ScriptInterpreter
var argvJSON, createdAt, updatedAt string
if err := rows.Scan(
&si.ID,
&si.Name,
&si.Label,
&argvJSON,
&createdAt,
&updatedAt,
); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
return nil, err
}
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
interpreters = append(interpreters, si)
}
return interpreters, rows.Err()
}
func (r *ScriptInterpreterRepo) Update(
ctx context.Context,
id int64,
in ScriptInterpreterUpdate,
) (*ScriptInterpreter, error) {
si, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
set := ""
args := make([]interface{}, 0)
idx := 1
if in.Name != nil {
set += "name = ?"
args = append(args, *in.Name)
idx++
}
if in.Label != nil {
if idx > 1 {
set += ", "
}
set += "label = ?"
args = append(args, *in.Label)
idx++
}
if in.Argv != nil {
if idx > 1 {
set += ", "
}
argvJSON, err := json.Marshal(in.Argv)
if err != nil {
return nil, err
}
set += "argv = ?"
args = append(args, string(argvJSON))
idx++
}
if idx == 1 {
return si, nil
}
set += ", updated_at = CURRENT_TIMESTAMP"
args = append(args, id)
_, err = r.DB.ExecContext(ctx,
`UPDATE script_interpreters SET `+set+` WHERE id = ?`,
args...,
)
if err != nil {
return nil, err
}
return r.GetByID(ctx, id)
}
func (r *ScriptInterpreterRepo) Delete(ctx context.Context, id int64) error {
result, err := r.DB.ExecContext(ctx,
`DELETE FROM script_interpreters WHERE id = ?`,
id,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
)
// ScriptService handles script CRUD, tree building, and interpreter resolution.
type ScriptService struct {
Repo *repository.Repository
InterpreterRepo *repository.ScriptInterpreterRepo
}
// NewScriptService creates a new ScriptService with both script and interpreter repos.
func NewScriptService(repo *repository.Repository) *ScriptService {
return &ScriptService{Repo: repo}
}
// NewScriptServiceWithInterpreters creates a ScriptService with interpreter support.
func NewScriptServiceWithInterpreters(repo *repository.Repository, interpRepo *repository.ScriptInterpreterRepo) *ScriptService {
return &ScriptService{Repo: repo, InterpreterRepo: interpRepo}
}
// treeNode is an internal representation for building the tree.
type treeNode struct {
name string
typ string // "folder" or "file"
children map[string]*treeNode
// File-specific fields
id *int64
content *string
interpreterID *int64
}
// BuildTree builds a directory tree from all scripts in the database.
// Each script path is treated as a file path (e.g. "deploy/nginx/restart.sh").
// Scripts with empty content and interpreter_id=0 are treated as folder placeholders.
func (s *ScriptService) BuildTree() ([]repository.ScriptTreeNode, error) {
scripts, err := s.Repo.ListScripts()
if err != nil {
return nil, err
}
root := make(map[string]*treeNode)
for _, sc := range scripts {
parts := strings.Split(sc.Path, "/")
// A script with empty content and interpreter_id=0 is a folder placeholder
isPlaceholder := sc.Content == "" && sc.InterpreterID == 0
// Walk through path parts, creating folders as needed
currentMap := root
for i, part := range parts {
isLastPart := i == len(parts)-1
isFile := isLastPart && !isPlaceholder
if _, exists := currentMap[part]; !exists {
node := &treeNode{
name: part,
children: make(map[string]*treeNode),
}
if isFile {
node.typ = "file"
id := sc.ID
content := sc.Content
interpreterID := sc.InterpreterID
node.id = &id
node.content = &content
node.interpreterID = &interpreterID
} else {
node.typ = "folder"
}
currentMap[part] = node
} else if isFile {
// Node already exists but was created as a folder (e.g. by another script's path).
// Convert it to a file if it was a folder placeholder.
existing := currentMap[part]
if existing.typ == "folder" {
id := sc.ID
content := sc.Content
interpreterID := sc.InterpreterID
existing.typ = "file"
existing.id = &id
existing.content = &content
existing.interpreterID = &interpreterID
}
}
currentMap = currentMap[part].children
}
}
return buildTreeSlice(root), nil
}
// buildTreeSlice converts a map of treeNodes to a sorted slice of ScriptTreeNode.
func buildTreeSlice(m map[string]*treeNode) []repository.ScriptTreeNode {
result := make([]repository.ScriptTreeNode, 0, len(m))
for _, node := range m {
result = append(result, toScriptTreeNode(node))
}
// Sort: folders first, then files, alphabetically within each group
sort.Slice(result, func(i, j int) bool {
if result[i].Type != result[j].Type {
return result[i].Type == "folder"
}
return result[i].Name < result[j].Name
})
return result
}
// toScriptTreeNode converts a treeNode to a ScriptTreeNode with recursively converted children.
func toScriptTreeNode(node *treeNode) repository.ScriptTreeNode {
result := repository.ScriptTreeNode{
Name: node.name,
Type: node.typ,
Children: []repository.ScriptTreeNode{},
}
if node.typ == "file" {
result.ID = node.id
result.Content = node.content
result.InterpreterID = node.interpreterID
} else {
result.Children = buildTreeSlice(node.children)
}
return result
}
// ResolveCommand resolves the full command for a script using its interpreter.
func (s *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) {
if s.InterpreterRepo == nil {
return nil, fmt.Errorf("interpreter repo not configured")
}
interpreter, err := s.InterpreterRepo.GetByID(ctx, interpreterID)
if err != nil {
return nil, fmt.Errorf("get interpreter: %w", err)
}
// Build command: argv[0] argv[1] ... -c scriptText
cmd := append(interpreter.Argv, "-c", scriptText)
return cmd, nil
}
// List returns all interpreters.
func (s *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) {
if s.InterpreterRepo == nil {
return nil, fmt.Errorf("interpreter repo not configured")
}
return s.InterpreterRepo.List(ctx)
}
// Create creates a new interpreter.
func (s *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) {
if s.InterpreterRepo == nil {
return nil, fmt.Errorf("interpreter repo not configured")
}
return s.InterpreterRepo.Create(ctx, in)
}
// GetByID returns an interpreter by ID.
func (s *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) {
if s.InterpreterRepo == nil {
return nil, fmt.Errorf("interpreter repo not configured")
}
return s.InterpreterRepo.GetByID(ctx, id)
}
// Update updates an interpreter.
func (s *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) {
if s.InterpreterRepo == nil {
return nil, fmt.Errorf("interpreter repo not configured")
}
return s.InterpreterRepo.Update(ctx, id, in)
}
// Delete deletes an interpreter.
func (s *ScriptService) Delete(ctx context.Context, id int64) error {
if s.InterpreterRepo == nil {
return fmt.Errorf("interpreter repo not configured")
}
return s.InterpreterRepo.Delete(ctx, id)
}
+17 -3
View File
@@ -43,7 +43,11 @@ func OpenClickHouse(cfg ClickHouseConfig) (*sql.DB, error) {
}
// OpenClickHouseWithRetry attempts to connect to ClickHouse with retries and backoff.
func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay time.Duration) (*sql.DB, error) {
func OpenClickHouseWithRetry(
cfg ClickHouseConfig,
maxRetries int,
initialDelay time.Duration,
) (*sql.DB, error) {
var lastErr error
delay := initialDelay
@@ -53,10 +57,20 @@ func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay
return db, nil
}
lastErr = err
log.Printf("ClickHouse connection attempt %d/%d failed: %v, retrying in %v...", i+1, maxRetries, err, delay)
log.Printf(
"ClickHouse connection attempt %d/%d failed: %v, retrying in %v...",
i+1,
maxRetries,
err,
delay,
)
time.Sleep(delay)
delay *= 2
}
return nil, fmt.Errorf("clickhouse connection failed after %d attempts: %w", maxRetries, lastErr)
return nil, fmt.Errorf(
"clickhouse connection failed after %d attempts: %w",
maxRetries,
lastErr,
)
}
+317
View File
@@ -46,6 +46,29 @@ CREATE TABLE IF NOT EXISTS jobs (
);
`
const CreateScriptInterpretersTable = `
CREATE TABLE IF NOT EXISTS script_interpreters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
argv TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
const CreateScriptsTable = `
CREATE TABLE IF NOT EXISTS scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
interpreter_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (interpreter_id) REFERENCES script_interpreters(id)
);
`
const CreateLogsTable = `
CREATE TABLE IF NOT EXISTS logs (
timestamp DateTime64(3) DEFAULT now(),
@@ -58,3 +81,297 @@ ORDER BY (timestamp, level, service, agent)
TTL timestamp + INTERVAL 30 DAY
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);
`
+18 -1
View File
@@ -3,6 +3,7 @@ package storage
import (
"database/sql"
"fmt"
"log"
"strings"
_ "modernc.org/sqlite"
@@ -37,7 +38,23 @@ func Open(path string) (*sql.DB, error) {
}
// Migration: add is_active column if it doesn't exist
_, _ = db.Exec(AddIsActiveColumn)
if _, err := db.Exec(AddIsActiveColumn); err != nil {
log.Printf("[sqlite] WARNING: failed to add is_active column: %v", err)
} else {
log.Println("[sqlite] is_active column migration applied")
}
// Create scripts table if not exists
if _, err := db.Exec(CreateScriptsTable); err != nil {
return nil, fmt.Errorf("migrate scripts: %w", err)
}
// Seed default diagnostic scripts
if _, err := db.Exec(SeedDefaultScripts); err != nil {
log.Printf("[sqlite] WARNING: failed to seed default scripts: %v", err)
} else {
log.Println("[sqlite] default scripts seeded successfully")
}
return db, nil
}
+1
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
)
// TOOD: fuck
func RandomToken() (string, error) {
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
+3 -1
View File
@@ -7,7 +7,9 @@
"Bash(type *)",
"Bash(dir)",
"Bash(move *)",
"Bash(findstr *)"
"Bash(findstr *)",
"Bash(del *)",
"Bash(mkdir *)"
]
},
"$version": 3
+7152
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -11,19 +11,24 @@
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.2.2",
"@uiw/react-codemirror": "^4.25.8",
"axios": "^1.13.6",
"file-surf": "^1.0.3",
"framer-motion": "^12.38.0",
"monaco-languageclient": "^10.7.0",
"primeicons": "^7.0.0",
"primereact": "^10.9.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-2d": "^1.29.1",
"react-icons": "^5.6.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwind": "^4.0.0",
"tailwindcss": "^4.2.2",
"vscode-ws-jsonrpc": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
+13
View File
@@ -1,11 +1,24 @@
import { useState, useEffect } from "react";
import "@/shared/styles/index.css";
import "primereact/resources/themes/lara-light-cyan/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
import { PrimeReactProvider } from "primereact/api";
import { Routing } from "./providers/routing/routing";
import { AppLoader } from "./components/AppLoader";
function App() {
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 1800);
return () => clearTimeout(timer);
}, []);
if (loading) {
return <AppLoader />;
}
return (
<PrimeReactProvider>
<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 { useAuthStore } from "@/modules/auth/store/useAuthStore";
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useAuthStore();
interface ProtectedRouteProps {
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 />;
}
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}</>;
};
+190 -9
View File
@@ -1,11 +1,113 @@
import { Suspense } from "react";
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
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 { RegisterPage } from "@/pages/register.page";
import { AddAgentsPage } from "@/pages/add-agents.page";
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 = () => {
return (
@@ -17,15 +119,94 @@ export const Routing = () => {
}
>
<ReactRoutes>
<Route element={<DefaultLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/themes" element={<ThemesPage />} />
<Route path="/add-agents" element={<AddAgentsPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<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 path="/test" element={<TestPage />} />
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</ReactRoutes>
</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,
FiTrash2,
FiSettings,
FiLink,
} from "react-icons/fi";
import { SiDocker } from "react-icons/si";
import { FiPackage, FiUploadCloud } from "react-icons/fi";
@@ -20,8 +21,10 @@ interface ExtraField {
}
export interface SSHAgentConfig {
agentLabel: string;
user: string;
ip: string;
port: number;
authMethod: AuthMethod;
sshKey?: string;
password?: string;
@@ -189,11 +192,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
</div>
<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
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridTemplateColumns: "1fr 1fr 1fr",
gap: "16px",
}}
>
@@ -238,6 +261,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
placeholder="192.168.1.1"
/>
</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>
{/* Метод аутентификации */}
@@ -457,7 +505,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateColumns: "1fr 1fr",
gap: "8px",
}}
>
+16 -10
View File
@@ -17,13 +17,18 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
return response.data;
};
const register = async (data: RegisterData): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>("/auth/register", {
login: data.login,
password: data.password,
name: data.firstName,
last_name: data.lastName,
});
const register = async (
data: RegisterData,
): Promise<Record<string, string>> => {
const response = await apiClient.post<Record<string, string>>(
"/auth/register",
{
login: data.login,
password: data.password,
name: data.firstName,
last_name: data.lastName,
},
);
return response.data;
};
@@ -62,9 +67,10 @@ export const useAuthStore = create<AuthState>()(
register: async (data: RegisterData) => {
set({ isLoading: true, error: null });
try {
const response = await register(data);
const user = mapResponseToUser(response);
set({ user, token: response.token, isLoading: false });
await register(data);
// После регистрации пользователь не авторизуется автоматически
// Нужно войти через /auth/login
set({ isLoading: false });
} catch (error) {
set({
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;

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