Compare commits
82 Commits
1f6908900b
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5214f183ff | |||
| 56db916f29 | |||
| 0a2d41d04e | |||
| a70791898c | |||
| 26323dfd15 | |||
| 7d2f3d0f3a | |||
| 255fe2eaf3 | |||
| 6d6dd91241 | |||
| 915aa7018a | |||
| eb8aef11a4 | |||
| 4a00c95d25 | |||
| e9fdaf8711 | |||
| 413e31c711 | |||
| c175461634 | |||
| f26fa3da69 | |||
| 5b90447984 | |||
| 247505a310 | |||
| ad9d567d2c | |||
| c6c46aee68 | |||
| 9f6defd25c | |||
| 2714bd1178 | |||
| 7aa25b02c5 | |||
| 5f6c4303db | |||
| 17d4770de6 | |||
| 337e5891f3 | |||
| 2bc3da21fd | |||
| d79e9dd829 | |||
| a4b7024bb8 | |||
| 87f3836657 | |||
| c2e8037560 | |||
| 54e8102a51 | |||
| 5ccb752836 | |||
| 2616669ab1 | |||
| 71a8fa154b | |||
| d6512d6c97 | |||
| b1e6775f1b | |||
| 8226429b5b | |||
| add1242b97 | |||
| 5475912365 | |||
| b86c36d996 | |||
| 6eacc79445 | |||
| f14490c076 | |||
| 178c3b53f7 | |||
| 5073cfd357 | |||
| f71a3b1a03 | |||
| e024f91111 | |||
| 8f5558fdb7 | |||
| 07066ec8c0 | |||
| 31eecf4ba5 | |||
| cf6065b55a | |||
| 43ea41f633 | |||
| 6b82c99d50 | |||
| c73035019f | |||
| e3fae7a02c | |||
| d46d0f8253 | |||
| bcca8fa298 | |||
| 400ceab47c | |||
| c6a9907822 | |||
| 69ff617c30 | |||
| 3430070df8 | |||
| 78f35f6811 | |||
| 55cb214458 | |||
| 8175d7b3a5 | |||
| 822f953698 | |||
| e7f1ea2386 | |||
| aac3fa3758 | |||
| 26ca7c0d51 | |||
| dd921e5892 | |||
| eedc9c9b62 | |||
| 4f69e002c6 | |||
| 5209e8b2e9 | |||
| 95a6902dae | |||
| adbb0ee368 | |||
| 96f82b4162 | |||
| ed439656f8 | |||
| d62205b329 | |||
| 11cef95929 | |||
| 43e16b1360 | |||
| f537f1eab9 | |||
| 9d1096a9b4 | |||
| 57b43da2e3 | |||
| 691e1fced5 |
@@ -0,0 +1,32 @@
|
||||
name: release-agent
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Go setup
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
workdir: agent
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
@@ -1 +1,358 @@
|
||||
# HellreigN
|
||||
# HellreigN
|
||||
|
||||
Агент внутренней диагностики инфраструктуры. Централизованный сбор логов, мониторинг нагрузки, управление скриптами и контроль состояния сервисов.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Сбор логов** — journald, Docker, Kubernetes, файлы
|
||||
- **Метрики нагрузки** — CPU, RAM, диск, сеть в реальном времени
|
||||
- **Контроль сервисов** — проверка alive/dead для systemd и Docker
|
||||
- **Удалённое выполнение команд** — запуск скриптов и команд на агентах
|
||||
- **Граф зависимостей** — определение причин сбоев, порядок запуска
|
||||
- **Офлайн-буфер** — логи не теряются при потере связи
|
||||
- **mTLS** — защищённое соединение между агентом и бэкендом
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Инфраструктура │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Agent 1 │ │ Agent 2 │ │ Agent N │ │
|
||||
│ │ (хост) │ │ (docker) │ │ (k8s) │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────┼──────────────┘ │
|
||||
│ │ gRPC (mTLS) │
|
||||
│ ▼ │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ Backend │ ◄── REST API │
|
||||
│ │ :8080 / :9001│ │
|
||||
│ └───────┬───────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ ClickHouse │ ◄── Хранилище логов │
|
||||
│ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ HTTP
|
||||
┌───────┴───────┐
|
||||
│ Frontend │
|
||||
│ :3000 │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Требования
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Go 1.26.1+ (для локальной разработки)
|
||||
|
||||
### Деплой
|
||||
|
||||
```bash
|
||||
cd infra
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Поднимутся:
|
||||
- **ClickHouse** — хранилище логов
|
||||
- **Backend** — API (`8080`) + gRPC (`9001`)
|
||||
- **Frontend** — веб-интерфейс (`3000`)
|
||||
- **Agent** — пример агента
|
||||
|
||||
Откройте `http://localhost:3000`. Логин: `admin`, пароль: `admin123`.
|
||||
Что бы агент заработал нужно в веб интрфейсе найти кнопку создать токен получить его и вписать в конфигурацию агента
|
||||
### Локальная разработка
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && go run ./cmd/main.go
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# Agent
|
||||
cd agent && CONFIG_FILE=./config.yml go run main.go
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
### Backend
|
||||
|
||||
`infra/backend/config.yml`:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
token_db: /var/lib/hellreign/tokens.db
|
||||
clickhouse_host: clickhouse:9000
|
||||
clickhouse_user: default
|
||||
clickhouse_password: testpassword
|
||||
clickhouse_database: hellreign
|
||||
admin:
|
||||
admin_name: Admin
|
||||
admin_last_name: User
|
||||
admin_login: admin
|
||||
admin_password: admin123
|
||||
```
|
||||
|
||||
### Агент
|
||||
|
||||
`infra/agent/config.yml`:
|
||||
|
||||
```yaml
|
||||
backend_url: http://backend:8080
|
||||
grpc_url: backend:9001
|
||||
label: production-server-1
|
||||
registration_token: "token-из-ui"
|
||||
cert_dir: /etc/hellreign-agent/certs
|
||||
services:
|
||||
# journald + проверка systemd
|
||||
- name: nginx
|
||||
type: journald
|
||||
systemd_unit: nginx.service
|
||||
|
||||
# Docker контейнер
|
||||
- name: redis
|
||||
type: docker
|
||||
|
||||
# Файл
|
||||
- name: myapp
|
||||
type: file
|
||||
path: /var/log/myapp/app.log
|
||||
```
|
||||
|
||||
Поле `systemd_unit` опционально. Если указано — агент проверяет `systemctl is-active` и шлёт статус `up`/`down`. Для Docker — `docker inspect {{.State.Running}}`.
|
||||
|
||||
### Граф зависимостей
|
||||
|
||||
`infra/services.yaml`:
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
production-server-1:
|
||||
services:
|
||||
nginx:
|
||||
depends_on: [sshd]
|
||||
sshd:
|
||||
depends_on: []
|
||||
```
|
||||
|
||||
Используется для определения причины сбоев и порядка запуска.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Backend
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|------------|-------------|----------|
|
||||
| `CONFIG_FILE` | `/etc/hellreign/config.yml` | Путь к YAML конфигу |
|
||||
| `GRAPH_YAML_PATH` | `/etc/hellreign/services.yaml` | Путь к графу сервисов |
|
||||
| `SSL_CERT_DIR` | `/var/lib/hellreign/ssl` | Директория mTLS сертификатов |
|
||||
| `SERVER_SAN_DNS` | `localhost,backend` | SAN DNS сертификата |
|
||||
| `SERVER_SAN_IP` | `127.0.0.1` | SAN IP сертификата |
|
||||
| `GRPC_PORT` | `9001` | Порт gRPC |
|
||||
| `GIN_MODE` | `release` | Режим Gin |
|
||||
|
||||
### Агент
|
||||
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|------------|-------------|----------|
|
||||
| `CONFIG_FILE` | `/etc/hellreign-agent/config.yml` | Путь к YAML конфигу |
|
||||
| `JOURNALD_LOGDIR` | `/var/log/journal` | Директория journald (ro) |
|
||||
| `BUFFER_DB` | `/var/lib/hellreign-agent/agent_buffer.db` | SQLite буфер |
|
||||
| `IS_DEBUG` | `0` | Debug логи (`1`/`0`) |
|
||||
|
||||
## Порты
|
||||
|
||||
| Сервис | Порт | Назначение |
|
||||
|--------|------|------------|
|
||||
| Frontend | `3000` | Веб-интерфейс |
|
||||
| Backend HTTP | `8080` | REST API + Swagger |
|
||||
| Backend gRPC | `9001` | gRPC (mTLS) |
|
||||
| ClickHouse HTTP | `8123` | HTTP интерфейс |
|
||||
| ClickHouse Native | `9000` | Native протокол |
|
||||
|
||||
## API примеры
|
||||
|
||||
### Авторизация
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"login":"admin","password":"admin123"}'
|
||||
```
|
||||
|
||||
### Агенты и метрики
|
||||
|
||||
```bash
|
||||
# Список подключённых агентов
|
||||
curl http://localhost:8080/api/v1/agents \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
|
||||
# Метрики нагрузки (CPU, RAM, disk, network)
|
||||
curl http://localhost:8080/api/v1/agents/system-metrics \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
|
||||
### Логи
|
||||
|
||||
```bash
|
||||
# Поиск
|
||||
curl "http://localhost:8080/api/v1/logs?service=nginx&level=error" \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
|
||||
# Список сервисов
|
||||
curl http://localhost:8080/api/v1/logs/services \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
|
||||
### Скрипты
|
||||
|
||||
```bash
|
||||
# Дерево
|
||||
curl http://localhost:8080/api/v1/scripts/tree \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
|
||||
# Запуск на агенте
|
||||
curl -X POST http://localhost:8080/api/v1/scripts/1/run \
|
||||
-H "Authorization: Bearer <jwt>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"token":"agent-token"}'
|
||||
```
|
||||
|
||||
### Swagger
|
||||
|
||||
`http://localhost:8080/swagger/index.html`
|
||||
|
||||
Перегенерация:
|
||||
|
||||
```bash
|
||||
cd backend && swag init -g ./cmd/main.go --parseDependency --parseInternal
|
||||
```
|
||||
|
||||
## Деплой агента на хост
|
||||
|
||||
### 1. Директории
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/hellreign-agent/certs /var/lib/hellreign-agent
|
||||
```
|
||||
|
||||
### 2. Конфиг
|
||||
|
||||
```bash
|
||||
sudo nano /etc/hellreign-agent/config.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
backend_url: https://monitoring.example.com
|
||||
grpc_url: monitoring.example.com:9001
|
||||
label: prod-web-1
|
||||
registration_token: "token-из-ui"
|
||||
cert_dir: /etc/hellreign-agent/certs
|
||||
services:
|
||||
- name: nginx
|
||||
type: journald
|
||||
systemd_unit: nginx.service
|
||||
- name: postgres
|
||||
type: journald
|
||||
systemd_unit: postgresql.service
|
||||
```
|
||||
|
||||
### 3. Бинарь
|
||||
|
||||
Скачать из релиза Gitea или собрать:
|
||||
|
||||
```bash
|
||||
cd agent && go build -o hellreign-agent ./main.go
|
||||
sudo mv hellreign-agent /usr/bin/
|
||||
```
|
||||
|
||||
### 4. Systemd
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=HellreigN Agent
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/hellreign-agent
|
||||
Environment=CONFIG_FILE=/etc/hellreign-agent/config.yml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hellreign-agent
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
При пуше тега `v*` — GoReleaser собирает `.deb` и `.rpm` для `linux/amd64` и `linux/arm64`:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0 && git push origin v1.0.0
|
||||
```
|
||||
|
||||
Требует секрет `GITEA_TOKEN` в настройках репозитория.
|
||||
|
||||
## Proto
|
||||
|
||||
После изменений в `proto/hellreign.proto`:
|
||||
|
||||
```bash
|
||||
cd proto
|
||||
protoc --go_out=. --go_opt=paths=source_relative \
|
||||
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
hellreign.proto
|
||||
mv hellreign*.go proto/
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
HellreigN/
|
||||
├── agent/ # Агент диагностики
|
||||
│ ├── main.go
|
||||
│ └── internal/
|
||||
│ ├── buffer/ # SQLite буфер (офлайн-доставка)
|
||||
│ ├── client/ # gRPC клиент команд
|
||||
│ ├── commander/ # Исполнитель команд
|
||||
│ ├── config/ # YAML конфиг
|
||||
│ ├── metrics/ # Сбор CPU, RAM, disk, network
|
||||
│ ├── logsource/ # Источники логов
|
||||
│ │ ├── docker/
|
||||
│ │ ├── file/
|
||||
│ │ ├── journald/
|
||||
│ │ └── kubernetes/
|
||||
│ ├── mtls/ # mTLS credentials
|
||||
│ └── registration/ # Регистрация
|
||||
├── backend/ # Бэкенд API
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── handlers/ # HTTP хендлеры
|
||||
│ ├── repository/ # SQLite репозитории
|
||||
│ ├── grpcsrv/
|
||||
│ │ ├── commander/ # Выполнение команд
|
||||
│ │ └── collector/ # Сбор логов и метрик
|
||||
│ ├── auth/ # JWT
|
||||
│ └── storage/ # ClickHouse
|
||||
├── frontend/ # React + TypeScript
|
||||
├── infra/ # Docker Compose
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── services.yaml # Граф зависимостей
|
||||
│ ├── backend/config.yml
|
||||
│ ├── agent/config.yml
|
||||
│ └── clickhouse/init/
|
||||
├── migrations/ # SQL миграции SQLite
|
||||
└── proto/ # Protobuf
|
||||
```
|
||||
|
||||
+3
-20
@@ -12,7 +12,7 @@ gitea_urls:
|
||||
|
||||
builds:
|
||||
- id: banforge
|
||||
main: ./cmd/banforge/main.go
|
||||
main: ./main.go
|
||||
binary: banforge
|
||||
ignore:
|
||||
- goos: windows
|
||||
@@ -24,12 +24,9 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- "-s -w"
|
||||
- "-s -w"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
nfpms:
|
||||
- id: banforge
|
||||
@@ -40,23 +37,9 @@ nfpms:
|
||||
maintainer: d3m0k1d <contact@d3m0k1d.ru>
|
||||
license: GPLv3.0
|
||||
formats:
|
||||
- apk
|
||||
- deb
|
||||
- rpm
|
||||
- archlinux
|
||||
bindir: /usr/bin
|
||||
scripts:
|
||||
postinstall: build/postinstall.sh
|
||||
postremove: build/postremove.sh
|
||||
contents:
|
||||
- src: docs/man/banforge.1
|
||||
dst: /usr/share/man/man1/banforge.1
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: docs/man/banforge.5
|
||||
dst: /usr/share/man/man5/banforge.5
|
||||
file_info:
|
||||
mode: 0644
|
||||
release:
|
||||
gitea:
|
||||
owner: d3m0k1d
|
||||
@@ -74,7 +57,7 @@ checksum:
|
||||
algorithm: sha256
|
||||
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
- artifacts: any
|
||||
documents:
|
||||
- "{{ .ArtifactName }}.spdx.json"
|
||||
cmd: syft
|
||||
|
||||
@@ -5,13 +5,65 @@ go 1.26.1
|
||||
require (
|
||||
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
@@ -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=
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SystemMetrics holds current system resource usage.
|
||||
type SystemMetrics struct {
|
||||
CPUPercent float64
|
||||
MemoryPercent float64
|
||||
DiskPercent float64
|
||||
NetworkRxBytes float64
|
||||
NetworkTxBytes float64
|
||||
}
|
||||
|
||||
// Collector collects system metrics from /proc and sysfs.
|
||||
type Collector struct {
|
||||
lastCPUTotal uint64
|
||||
lastCPUIdle uint64
|
||||
lastNetRx float64
|
||||
lastNetTx float64
|
||||
lastNetTime time.Time
|
||||
}
|
||||
|
||||
// NewCollector creates a new metrics collector.
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{}
|
||||
}
|
||||
|
||||
// Collect gathers current system metrics.
|
||||
func (c *Collector) Collect() (SystemMetrics, error) {
|
||||
var m SystemMetrics
|
||||
|
||||
cpu, err := c.readCPU()
|
||||
if err == nil {
|
||||
m.CPUPercent = cpu
|
||||
}
|
||||
|
||||
mem, err := c.readMemory()
|
||||
if err == nil {
|
||||
m.MemoryPercent = mem
|
||||
}
|
||||
|
||||
disk, err := c.readDisk("/")
|
||||
if err == nil {
|
||||
m.DiskPercent = disk
|
||||
}
|
||||
|
||||
netRx, netTx, err := c.readNetwork()
|
||||
if err == nil {
|
||||
m.NetworkRxBytes = netRx
|
||||
m.NetworkTxBytes = netTx
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// readCPU returns CPU usage percentage since last call.
|
||||
func (c *Collector) readCPU() (float64, error) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 8 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var user, nice, system, idle, iowait, irq, softirq uint64
|
||||
user, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||
nice, _ = strconv.ParseUint(fields[2], 10, 64)
|
||||
system, _ = strconv.ParseUint(fields[3], 10, 64)
|
||||
idle, _ = strconv.ParseUint(fields[4], 10, 64)
|
||||
iowait, _ = strconv.ParseUint(fields[5], 10, 64)
|
||||
irq, _ = strconv.ParseUint(fields[6], 10, 64)
|
||||
softirq, _ = strconv.ParseUint(fields[7], 10, 64)
|
||||
|
||||
total := user + nice + system + idle + iowait + irq + softirq
|
||||
idleTotal := idle + iowait
|
||||
|
||||
if c.lastCPUTotal > 0 {
|
||||
totalDiff := total - c.lastCPUTotal
|
||||
idleDiff := idleTotal - c.lastCPUIdle
|
||||
|
||||
if totalDiff > 0 {
|
||||
cpuPercent := float64(totalDiff-idleDiff) / float64(totalDiff) * 100.0
|
||||
c.lastCPUTotal = total
|
||||
c.lastCPUIdle = idleTotal
|
||||
return cpuPercent, nil
|
||||
}
|
||||
}
|
||||
|
||||
c.lastCPUTotal = total
|
||||
c.lastCPUIdle = idleTotal
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return 0, scanner.Err()
|
||||
}
|
||||
|
||||
// readMemory returns RAM usage percentage.
|
||||
func (c *Collector) readMemory() (float64, error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var total, available uint64
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
total, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||
} else if strings.HasPrefix(line, "MemAvailable:") {
|
||||
fields := strings.Fields(line)
|
||||
available, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
used := total - available
|
||||
return float64(used) / float64(total) * 100.0, nil
|
||||
}
|
||||
|
||||
// readDisk returns disk usage percentage for the given path.
|
||||
func (c *Collector) readDisk(path string) (float64, error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
free := stat.Bfree * uint64(stat.Bsize)
|
||||
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
used := total - free
|
||||
return float64(used) / float64(total) * 100.0, nil
|
||||
}
|
||||
|
||||
// readNetwork returns network RX/TX bytes per second.
|
||||
func (c *Collector) readNetwork() (float64, float64, error) {
|
||||
f, err := os.Open("/proc/net/dev")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var totalRx, totalTx uint64
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip header lines
|
||||
if strings.Contains(line, "|") || strings.HasPrefix(strings.TrimSpace(line), "Inter") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimSpace(line), ":", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(parts[1])
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
|
||||
rx, _ := strconv.ParseUint(fields[0], 10, 64)
|
||||
tx, _ := strconv.ParseUint(fields[8], 10, 64)
|
||||
totalRx += rx
|
||||
totalTx += tx
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var rxRate, txRate float64
|
||||
|
||||
if !c.lastNetTime.IsZero() {
|
||||
elapsed := now.Sub(c.lastNetTime).Seconds()
|
||||
if elapsed > 0 {
|
||||
rxRate = float64(totalRx) - c.lastNetRx
|
||||
txRate = float64(totalTx) - c.lastNetTx
|
||||
// Convert to bytes per second
|
||||
rxRate = rxRate / elapsed
|
||||
txRate = txRate / elapsed
|
||||
}
|
||||
}
|
||||
|
||||
c.lastNetRx = float64(totalRx)
|
||||
c.lastNetTx = float64(totalTx)
|
||||
c.lastNetTime = now
|
||||
|
||||
return rxRate, txRate, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
// ServiceStatus represents the unified status of a service across all monitor types.
|
||||
type ServiceStatus string
|
||||
|
||||
const (
|
||||
StatusRunning ServiceStatus = "running"
|
||||
StatusStopped ServiceStatus = "stopped"
|
||||
StatusDegraded ServiceStatus = "degraded"
|
||||
StatusPending ServiceStatus = "pending"
|
||||
StatusUnknown ServiceStatus = "unknown"
|
||||
)
|
||||
|
||||
// IsHealthy reports whether the service is stable enough for dependents to rely on.
|
||||
func (s ServiceStatus) IsHealthy() bool {
|
||||
return s == StatusRunning
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Name string
|
||||
Status ServiceStatus
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/models"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/monitor"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
moby "github.com/moby/moby/client"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
var _ monitor.ServiceMonitor = new(DockerMonitor)
|
||||
|
||||
type DockerMonitor struct{}
|
||||
|
||||
func New() *DockerMonitor {
|
||||
return &DockerMonitor{}
|
||||
}
|
||||
|
||||
func (self *DockerMonitor) CheckServices(ctx context.Context) ([]models.Service, error) {
|
||||
client, err := moby.New(moby.FromEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctrs, err := client.ContainerList(ctx, moby.ContainerListOptions{
|
||||
Size: false,
|
||||
All: false,
|
||||
Limit: 0,
|
||||
Filters: moby.Filters{},
|
||||
Latest: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lo.Map(ctrs.Items, func(item container.Summary, _ int) models.Service {
|
||||
return models.Service{
|
||||
Name: lo.If(len(item.Names) > 0, item.Names[0]).Else(item.ID),
|
||||
Status: mapContainerState(string(item.State)),
|
||||
}
|
||||
}), nil
|
||||
}
|
||||
|
||||
// mapContainerState maps Docker container states to unified ServiceStatus.
|
||||
func mapContainerState(state string) models.ServiceStatus {
|
||||
switch state {
|
||||
case "running":
|
||||
return models.StatusRunning
|
||||
case "exited", "dead":
|
||||
return models.StatusStopped
|
||||
case "paused":
|
||||
return models.StatusDegraded
|
||||
case "restarting", "created", "removing":
|
||||
return models.StatusPending
|
||||
default:
|
||||
return models.StatusUnknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/models"
|
||||
)
|
||||
|
||||
type ServiceMonitor interface {
|
||||
CheckServices(ctx context.Context) ([]models.Service, error)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kubes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/samber/lo"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/models"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/monitor"
|
||||
)
|
||||
|
||||
var _ monitor.ServiceMonitor = new(KubesMonitor)
|
||||
|
||||
type KubesMonitor struct{ namespace string }
|
||||
|
||||
func New(namespace string) *KubesMonitor {
|
||||
return &KubesMonitor{namespace}
|
||||
}
|
||||
|
||||
func (self *KubesMonitor) CheckServices(ctx context.Context) ([]models.Service, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := clientcmd.BuildConfigFromFlags("", filepath.Join(home, ".kube", "config"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: consider moving all the shit above into constructor
|
||||
pods, err := clientset.CoreV1().Pods(self.namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lo.Map(pods.Items, func(item corev1.Pod, _ int) models.Service {
|
||||
return models.Service{
|
||||
Name: item.Name,
|
||||
Status: mapPodPhase(item.Status.Phase),
|
||||
}
|
||||
}), nil
|
||||
}
|
||||
|
||||
// mapPodPhase maps K8s pod phases to unified ServiceStatus.
|
||||
func mapPodPhase(phase corev1.PodPhase) models.ServiceStatus {
|
||||
switch phase {
|
||||
case corev1.PodRunning:
|
||||
return models.StatusRunning
|
||||
case corev1.PodSucceeded:
|
||||
return models.StatusStopped
|
||||
case corev1.PodFailed:
|
||||
return models.StatusStopped
|
||||
case corev1.PodPending:
|
||||
return models.StatusPending
|
||||
default:
|
||||
return models.StatusUnknown
|
||||
}
|
||||
}
|
||||
+110
-2
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ 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"
|
||||
@@ -120,6 +122,11 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
// Start system metrics reporting
|
||||
wg.Go(func() error {
|
||||
return reportSystemMetrics(ctx, grpcAddr, creds, cfg.Label, lgr)
|
||||
})
|
||||
|
||||
// Start log collectors
|
||||
if len(cfg.Services) > 0 {
|
||||
wg.Go(func() error {
|
||||
@@ -323,7 +330,6 @@ func reconnectStream(
|
||||
}
|
||||
|
||||
// reportServices periodically sends service status updates to the backend via gRPC.
|
||||
// For now, all configured services are reported as "up" every 5 seconds.
|
||||
func reportServices(
|
||||
ctx context.Context,
|
||||
grpcAddr string,
|
||||
@@ -346,9 +352,10 @@ func reportServices(
|
||||
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: "up",
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -370,3 +377,104 @@ func reportServices(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-6
@@ -93,18 +93,24 @@ func main() {
|
||||
// Initialize script interpreter repository and service
|
||||
scriptRepo := repository.NewScriptInterpreterRepo(db)
|
||||
if err := scriptRepo.Init(context.Background()); err != nil {
|
||||
log.Printf("Warning: failed to initialize script interpreters table: %v", err)
|
||||
log.Fatalf("Warning: failed to initialize script interpreters table: %v\n", err)
|
||||
}
|
||||
scriptSvc := service.NewScriptServiceWithInterpreters(h.Repo, scriptRepo)
|
||||
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdTracker)
|
||||
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdTracker,
|
||||
os.Getenv("WHEREAMI"))
|
||||
jobsHandlers := handlers.NewJobsHandlers(cmdTracker, scriptSvc,
|
||||
os.Getenv("WHEREAMI"), /* our address for redirects */
|
||||
jobRepo,
|
||||
)
|
||||
|
||||
// Initialize script management service and handlers
|
||||
scriptManageSvc := service.NewScriptService(h.Repo)
|
||||
scriptManageHandlers := handlers.NewScriptHandlersGroup(scriptManageSvc, cmdr)
|
||||
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}
|
||||
@@ -200,6 +206,7 @@ func main() {
|
||||
agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||
{
|
||||
agentsGroup.GET("", agents.List)
|
||||
agentsGroup.GET("/system-metrics", agents.GetSystemMetrics)
|
||||
}
|
||||
|
||||
// Jobs (requires admin permission)
|
||||
@@ -209,7 +216,27 @@ func main() {
|
||||
jobsGroup.POST("", jobsHandlers.AddJob)
|
||||
jobsGroup.POST("/:id/wait", jobsHandlers.WaitJob)
|
||||
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
|
||||
jobsGroup.POST("/check_cmd", jobsHandlers.CheckCmd)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+289
-98
@@ -177,6 +177,37 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agents/system-metrics": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns CPU, RAM, disk, and network usage metrics for all connected agents",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"agents"
|
||||
],
|
||||
"summary": "Get agent system metrics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/internal_handlers.AgentSystemMetricsOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate with login and password, returns a token and permissions",
|
||||
@@ -963,6 +994,195 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the service dependency graph as JSON",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Get dependency graph",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dependency graph",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Replaces the service dependency graph YAML and reloads it",
|
||||
"consumes": [
|
||||
"text/plain"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Update dependency graph YAML",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "New YAML content",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph/cycle": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns whether the dependency graph contains cycles",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Check for cycles",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph/failure": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Analyzes dependencies and service statuses to find the root cause of a failure",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Find failure root cause",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Node ID (agent label)",
|
||||
"name": "node_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Service name",
|
||||
"name": "service",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.FailureRootCauseOut"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph/order": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the topologically sorted service startup order",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Get startup order",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/jobs": {
|
||||
"post": {
|
||||
"description": "Sends a command to the specified agent and returns a URL to wait for the result",
|
||||
@@ -997,46 +1217,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/jobs/check_cmd": {
|
||||
"post": {
|
||||
"description": "Validates that a command binary exists on the system",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"jobs"
|
||||
],
|
||||
"summary": "Check command path",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Command to check",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.CheckCmdIn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.CheckCmdOut"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/jobs/metrics": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1059,6 +1239,12 @@ const docTemplate = `{
|
||||
"description": "Time period (e.g. 1h, 24h, 7d)",
|
||||
"name": "period",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by agent ID",
|
||||
"name": "agent_id",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1100,15 +1286,6 @@ const docTemplate = `{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Agent reference",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.WaitJobIn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1651,7 +1828,7 @@ const docTemplate = `{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Loads a script from storage, resolves interpreter command, and executes on the specified agent",
|
||||
"description": "Loads a script from storage, resolves interpreter command, and submits it to the agent",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1671,7 +1848,7 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Agent token and optional stdin",
|
||||
"description": "Agent ID and optional stdin",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -1684,7 +1861,7 @@ const docTemplate = `{
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.RunScriptOut"
|
||||
"$ref": "#/definitions/internal_handlers.AddJobOut"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -2118,7 +2295,7 @@ const docTemplate = `{
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.RunScriptOut"
|
||||
"$ref": "#/definitions/internal_handlers.AddJobOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2709,23 +2886,40 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.CheckCmdIn": {
|
||||
"internal_handlers.AgentSystemMetricsOut": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"properties": {
|
||||
"command": {
|
||||
"connected_at": {
|
||||
"type": "string",
|
||||
"example": "bash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.CheckCmdOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
"example": "2026-04-04 10:30:00"
|
||||
},
|
||||
"cpu_percent": {
|
||||
"type": "number",
|
||||
"example": 45.2
|
||||
},
|
||||
"disk_percent": {
|
||||
"type": "number",
|
||||
"example": 78.9
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"example": "agent-001"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"example": "web-server-1"
|
||||
},
|
||||
"memory_percent": {
|
||||
"type": "number",
|
||||
"example": 62.5
|
||||
},
|
||||
"network_rx_bytes": {
|
||||
"type": "number",
|
||||
"example": 1048576
|
||||
},
|
||||
"network_tx_bytes": {
|
||||
"type": "number",
|
||||
"example": 524288
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2753,6 +2947,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.FailureRootCauseOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affected": {
|
||||
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
|
||||
},
|
||||
"dependency_chain": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"root_cause": {
|
||||
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.InsertLogRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2904,32 +3115,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.RunScriptOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"stderr": {
|
||||
"type": "string"
|
||||
},
|
||||
"stdin": {
|
||||
"type": "string"
|
||||
},
|
||||
"stdout": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.RunStoredScriptIn": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2944,13 +3129,19 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.WaitJobIn": {
|
||||
"internal_handlers.ServiceStatusOut": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id"
|
||||
],
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"healthy": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
||||
+289
-98
@@ -166,6 +166,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/agents/system-metrics": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns CPU, RAM, disk, and network usage metrics for all connected agents",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"agents"
|
||||
],
|
||||
"summary": "Get agent system metrics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/internal_handlers.AgentSystemMetricsOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate with login and password, returns a token and permissions",
|
||||
@@ -952,6 +983,195 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the service dependency graph as JSON",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Get dependency graph",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dependency graph",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Replaces the service dependency graph YAML and reloads it",
|
||||
"consumes": [
|
||||
"text/plain"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Update dependency graph YAML",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "New YAML content",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph/cycle": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns whether the dependency graph contains cycles",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Check for cycles",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph/failure": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Analyzes dependencies and service statuses to find the root cause of a failure",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Find failure root cause",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Node ID (agent label)",
|
||||
"name": "node_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Service name",
|
||||
"name": "service",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.FailureRootCauseOut"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/graph/order": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the topologically sorted service startup order",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"graph"
|
||||
],
|
||||
"summary": "Get startup order",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/jobs": {
|
||||
"post": {
|
||||
"description": "Sends a command to the specified agent and returns a URL to wait for the result",
|
||||
@@ -986,46 +1206,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/jobs/check_cmd": {
|
||||
"post": {
|
||||
"description": "Validates that a command binary exists on the system",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"jobs"
|
||||
],
|
||||
"summary": "Check command path",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Command to check",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.CheckCmdIn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.CheckCmdOut"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/jobs/metrics": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1048,6 +1228,12 @@
|
||||
"description": "Time period (e.g. 1h, 24h, 7d)",
|
||||
"name": "period",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by agent ID",
|
||||
"name": "agent_id",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1089,15 +1275,6 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Agent reference",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.WaitJobIn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -1640,7 +1817,7 @@
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Loads a script from storage, resolves interpreter command, and executes on the specified agent",
|
||||
"description": "Loads a script from storage, resolves interpreter command, and submits it to the agent",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1660,7 +1837,7 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Agent token and optional stdin",
|
||||
"description": "Agent ID and optional stdin",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -1673,7 +1850,7 @@
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.RunScriptOut"
|
||||
"$ref": "#/definitions/internal_handlers.AddJobOut"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -2107,7 +2284,7 @@
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_handlers.RunScriptOut"
|
||||
"$ref": "#/definitions/internal_handlers.AddJobOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2698,23 +2875,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.CheckCmdIn": {
|
||||
"internal_handlers.AgentSystemMetricsOut": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"properties": {
|
||||
"command": {
|
||||
"connected_at": {
|
||||
"type": "string",
|
||||
"example": "bash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.CheckCmdOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
"example": "2026-04-04 10:30:00"
|
||||
},
|
||||
"cpu_percent": {
|
||||
"type": "number",
|
||||
"example": 45.2
|
||||
},
|
||||
"disk_percent": {
|
||||
"type": "number",
|
||||
"example": 78.9
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"example": "agent-001"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"example": "web-server-1"
|
||||
},
|
||||
"memory_percent": {
|
||||
"type": "number",
|
||||
"example": 62.5
|
||||
},
|
||||
"network_rx_bytes": {
|
||||
"type": "number",
|
||||
"example": 1048576
|
||||
},
|
||||
"network_tx_bytes": {
|
||||
"type": "number",
|
||||
"example": 524288
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2742,6 +2936,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.FailureRootCauseOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affected": {
|
||||
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
|
||||
},
|
||||
"dependency_chain": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"root_cause": {
|
||||
"$ref": "#/definitions/internal_handlers.ServiceStatusOut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.InsertLogRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2893,32 +3104,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.RunScriptOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"stderr": {
|
||||
"type": "string"
|
||||
},
|
||||
"stdin": {
|
||||
"type": "string"
|
||||
},
|
||||
"stdout": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.RunStoredScriptIn": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2933,13 +3118,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_handlers.WaitJobIn": {
|
||||
"internal_handlers.ServiceStatusOut": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id"
|
||||
],
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"healthy": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
||||
+190
-67
@@ -374,18 +374,32 @@ definitions:
|
||||
example: agent-001
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers.CheckCmdIn:
|
||||
internal_handlers.AgentSystemMetricsOut:
|
||||
properties:
|
||||
command:
|
||||
example: bash
|
||||
connected_at:
|
||||
example: "2026-04-04 10:30:00"
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
type: object
|
||||
internal_handlers.CheckCmdOut:
|
||||
properties:
|
||||
exists:
|
||||
type: boolean
|
||||
cpu_percent:
|
||||
example: 45.2
|
||||
type: number
|
||||
disk_percent:
|
||||
example: 78.9
|
||||
type: number
|
||||
id:
|
||||
example: agent-001
|
||||
type: string
|
||||
label:
|
||||
example: web-server-1
|
||||
type: string
|
||||
memory_percent:
|
||||
example: 62.5
|
||||
type: number
|
||||
network_rx_bytes:
|
||||
example: 1048576
|
||||
type: number
|
||||
network_tx_bytes:
|
||||
example: 524288
|
||||
type: number
|
||||
type: object
|
||||
internal_handlers.CreateFolderRequest:
|
||||
properties:
|
||||
@@ -403,6 +417,17 @@ definitions:
|
||||
required:
|
||||
- path
|
||||
type: object
|
||||
internal_handlers.FailureRootCauseOut:
|
||||
properties:
|
||||
affected:
|
||||
$ref: '#/definitions/internal_handlers.ServiceStatusOut'
|
||||
dependency_chain:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
root_cause:
|
||||
$ref: '#/definitions/internal_handlers.ServiceStatusOut'
|
||||
type: object
|
||||
internal_handlers.InsertLogRequest:
|
||||
properties:
|
||||
agent:
|
||||
@@ -504,23 +529,6 @@ definitions:
|
||||
- interpreter_id
|
||||
- script_text
|
||||
type: object
|
||||
internal_handlers.RunScriptOut:
|
||||
properties:
|
||||
command:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: integer
|
||||
stderr:
|
||||
type: string
|
||||
stdin:
|
||||
type: string
|
||||
stdout:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers.RunStoredScriptIn:
|
||||
properties:
|
||||
stdin:
|
||||
@@ -530,12 +538,16 @@ definitions:
|
||||
required:
|
||||
- token
|
||||
type: object
|
||||
internal_handlers.WaitJobIn:
|
||||
internal_handlers.ServiceStatusOut:
|
||||
properties:
|
||||
agent_id:
|
||||
healthy:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
node_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- agent_id
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
@@ -643,6 +655,26 @@ paths:
|
||||
summary: Create registration token
|
||||
tags:
|
||||
- agents
|
||||
/agents/system-metrics:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns CPU, RAM, disk, and network usage metrics for all connected
|
||||
agents
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/internal_handlers.AgentSystemMetricsOut'
|
||||
type: array
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get agent system metrics
|
||||
tags:
|
||||
- agents
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
@@ -1147,6 +1179,125 @@ paths:
|
||||
summary: Validate token
|
||||
tags:
|
||||
- auth
|
||||
/graph:
|
||||
get:
|
||||
description: Returns the service dependency graph as JSON
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Dependency graph
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get dependency graph
|
||||
tags:
|
||||
- graph
|
||||
put:
|
||||
consumes:
|
||||
- text/plain
|
||||
description: Replaces the service dependency graph YAML and reloads it
|
||||
parameters:
|
||||
- description: New YAML content
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Update dependency graph YAML
|
||||
tags:
|
||||
- graph
|
||||
/graph/cycle:
|
||||
get:
|
||||
description: Returns whether the dependency graph contains cycles
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Check for cycles
|
||||
tags:
|
||||
- graph
|
||||
/graph/failure:
|
||||
get:
|
||||
description: Analyzes dependencies and service statuses to find the root cause
|
||||
of a failure
|
||||
parameters:
|
||||
- description: Node ID (agent label)
|
||||
in: query
|
||||
name: node_id
|
||||
type: string
|
||||
- description: Service name
|
||||
in: query
|
||||
name: service
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.FailureRootCauseOut'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Find failure root cause
|
||||
tags:
|
||||
- graph
|
||||
/graph/order:
|
||||
get:
|
||||
description: Returns the topologically sorted service startup order
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get startup order
|
||||
tags:
|
||||
- graph
|
||||
/jobs:
|
||||
post:
|
||||
consumes:
|
||||
@@ -1182,12 +1333,6 @@ paths:
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Agent reference
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.WaitJobIn'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1210,32 +1355,6 @@ paths:
|
||||
summary: Wait for job result
|
||||
tags:
|
||||
- jobs
|
||||
/jobs/check_cmd:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Validates that a command binary exists on the system
|
||||
parameters:
|
||||
- description: Command to check
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.CheckCmdIn'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.CheckCmdOut'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Check command path
|
||||
tags:
|
||||
- jobs
|
||||
/jobs/metrics:
|
||||
get:
|
||||
description: Returns total, successful, failed, and pending job counts over
|
||||
@@ -1246,6 +1365,10 @@ paths:
|
||||
in: query
|
||||
name: period
|
||||
type: string
|
||||
- description: Filter by agent ID
|
||||
in: query
|
||||
name: agent_id
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1589,14 +1712,14 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Loads a script from storage, resolves interpreter command, and
|
||||
executes on the specified agent
|
||||
submits it to the agent
|
||||
parameters:
|
||||
- description: Script ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Agent token and optional stdin
|
||||
- description: Agent ID and optional stdin
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
@@ -1608,7 +1731,7 @@ paths:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.RunScriptOut'
|
||||
$ref: '#/definitions/internal_handlers.AddJobOut'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
@@ -1882,7 +2005,7 @@ paths:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers.RunScriptOut'
|
||||
$ref: '#/definitions/internal_handlers.AddJobOut'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Run a script on an agent
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// DepCondition represents how a service waits for a dependency.
|
||||
type DepCondition string
|
||||
|
||||
const (
|
||||
Started DepCondition = "started"
|
||||
Healthy DepCondition = "healthy"
|
||||
CompletedSuccessfully DepCondition = "completed_successfully"
|
||||
)
|
||||
|
||||
// ServiceRef uniquely identifies a service across nodes.
|
||||
// If NodeID is empty, it refers to a service in the same node.
|
||||
type ServiceRef struct {
|
||||
NodeID string `json:"node_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// String returns a human-readable reference like "node:service" or just "service".
|
||||
func (r ServiceRef) String() string {
|
||||
if r.NodeID != "" {
|
||||
return r.NodeID + ":" + r.Name
|
||||
}
|
||||
return r.Name
|
||||
}
|
||||
|
||||
// Dependency declares that a service depends on another service (possibly in a different node).
|
||||
type Dependency struct {
|
||||
Target ServiceRef `json:"target"`
|
||||
Condition DepCondition `json:"condition"`
|
||||
}
|
||||
|
||||
// Service represents a named service within a node with its dependency declarations.
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Dependencies []Dependency `json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
// Node represents a logical grouping of services (e.g., a server or cluster).
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Services []*Service `json:"services"`
|
||||
}
|
||||
|
||||
// Graph holds nodes, services, and computes dependency order.
|
||||
type Graph struct {
|
||||
nodes map[string]*Node
|
||||
// adj[key] = list of services that key depends on
|
||||
// key format: "nodeID:serviceName"
|
||||
adj map[string][]ServiceRef
|
||||
}
|
||||
|
||||
func New() *Graph {
|
||||
return &Graph{
|
||||
nodes: make(map[string]*Node),
|
||||
adj: make(map[string][]ServiceRef),
|
||||
}
|
||||
}
|
||||
|
||||
// AddNode adds a node to the graph.
|
||||
func (g *Graph) AddNode(nodeID string) *Node {
|
||||
if n, ok := g.nodes[nodeID]; ok {
|
||||
return n
|
||||
}
|
||||
n := &Node{ID: nodeID}
|
||||
g.nodes[nodeID] = n
|
||||
return n
|
||||
}
|
||||
|
||||
// AddService adds a service to a node.
|
||||
func (g *Graph) AddService(nodeID string, svc *Service) {
|
||||
node := g.AddNode(nodeID)
|
||||
node.Services = append(node.Services, svc)
|
||||
key := nodeID + ":" + svc.Name
|
||||
g.adj[key] = nil
|
||||
}
|
||||
|
||||
// ResolveRef resolves a ServiceRef to its full "nodeID:serviceName" key.
|
||||
// If ref.NodeID is empty, it's resolved relative to the given sourceNodeID.
|
||||
func (g *Graph) ResolveRef(ref ServiceRef, sourceNodeID string) (string, error) {
|
||||
nodeID := ref.NodeID
|
||||
if nodeID == "" {
|
||||
nodeID = sourceNodeID
|
||||
}
|
||||
key := nodeID + ":" + ref.Name
|
||||
if _, ok := g.adj[key]; !ok {
|
||||
return "", fmt.Errorf("unknown service %q", key)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// AddDependency adds a dependency: source service depends on target service.
|
||||
func (g *Graph) AddDependency(sourceNodeID, sourceName string, dep Dependency) error {
|
||||
srcKey := sourceNodeID + ":" + sourceName
|
||||
if _, ok := g.adj[srcKey]; !ok {
|
||||
return fmt.Errorf("unknown source service %q", srcKey)
|
||||
}
|
||||
|
||||
if _, err := g.ResolveRef(dep.Target, sourceNodeID); err != nil {
|
||||
return fmt.Errorf("dependency target invalid: %w", err)
|
||||
}
|
||||
|
||||
g.adj[srcKey] = append(g.adj[srcKey], dep.Target)
|
||||
|
||||
// Also update the Service struct for serialization
|
||||
node, ok := g.nodes[sourceNodeID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, svc := range node.Services {
|
||||
if svc.Name == sourceName {
|
||||
svc.Dependencies = append(svc.Dependencies, dep)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCycle detects if the dependency graph contains a cycle.
|
||||
func (g *Graph) HasCycle() bool {
|
||||
const (
|
||||
white = 0
|
||||
gray = 1
|
||||
black = 2
|
||||
)
|
||||
color := make(map[string]int)
|
||||
for key := range g.adj {
|
||||
color[key] = white
|
||||
}
|
||||
|
||||
var dfs func(string) bool
|
||||
dfs = func(u string) bool {
|
||||
color[u] = gray
|
||||
for _, depRef := range g.adj[u] {
|
||||
v, _ := g.ResolveRef(depRef, nodeIDFromKey(u))
|
||||
if color[v] == gray {
|
||||
return true
|
||||
}
|
||||
if color[v] == white && dfs(v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
color[u] = black
|
||||
return false
|
||||
}
|
||||
|
||||
for key := range g.adj {
|
||||
if color[key] == white {
|
||||
if dfs(key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TopologicalSort returns services in startup order (dependencies first).
|
||||
// Returns a flat list of "nodeID:serviceName" keys.
|
||||
func (g *Graph) TopologicalSort() ([]string, error) {
|
||||
if g.HasCycle() {
|
||||
return nil, fmt.Errorf("dependency cycle detected")
|
||||
}
|
||||
|
||||
var result []string
|
||||
visited := make(map[string]bool)
|
||||
|
||||
var dfs func(string)
|
||||
dfs = func(u string) {
|
||||
if visited[u] {
|
||||
return
|
||||
}
|
||||
visited[u] = true
|
||||
for _, depRef := range g.adj[u] {
|
||||
v, _ := g.ResolveRef(depRef, nodeIDFromKey(u))
|
||||
dfs(v)
|
||||
}
|
||||
result = append(result, u)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(g.adj))
|
||||
for k := range g.adj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
dfs(k)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNode returns a node by ID.
|
||||
func (g *Graph) GetNode(id string) (*Node, bool) {
|
||||
n, ok := g.nodes[id]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// GetService returns a service by node ID and name.
|
||||
func (g *Graph) GetService(nodeID, name string) (*Service, bool) {
|
||||
node, ok := g.nodes[nodeID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for _, s := range node.Services {
|
||||
if s.Name == name {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Nodes returns all nodes sorted by ID.
|
||||
func (g *Graph) Nodes() []*Node {
|
||||
result := make([]*Node, 0, len(g.nodes))
|
||||
for _, n := range g.nodes {
|
||||
result = append(result, n)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// nodeIDFromKey extracts the node ID from a "nodeID:serviceName" key.
|
||||
func nodeIDFromKey(key string) string {
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == ':' {
|
||||
return key[:i]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// yamlNode is the intermediate YAML representation of a node.
|
||||
type yamlNode struct {
|
||||
Services map[string]yamlService `yaml:"services"`
|
||||
}
|
||||
|
||||
// yamlService is the intermediate YAML representation of a service.
|
||||
type yamlService struct {
|
||||
DependsOn yamlDependsOn `yaml:"depends_on"`
|
||||
}
|
||||
|
||||
// yamlDependsOn supports both short form (list of strings) and long form (map with conditions).
|
||||
type yamlDependsOn struct {
|
||||
simple []string
|
||||
detail map[string]yamlDepCondition
|
||||
}
|
||||
|
||||
type yamlDepCondition struct {
|
||||
Condition DepCondition `yaml:"condition"`
|
||||
}
|
||||
|
||||
func (d *yamlDependsOn) UnmarshalYAML(value *yaml.Node) error {
|
||||
switch value.Kind {
|
||||
case yaml.SequenceNode:
|
||||
var names []string
|
||||
if err := value.Decode(&names); err != nil {
|
||||
return err
|
||||
}
|
||||
d.simple = names
|
||||
return nil
|
||||
case yaml.MappingNode:
|
||||
d.detail = make(map[string]yamlDepCondition)
|
||||
if err := value.Decode(&d.detail); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("depends_on must be a list or mapping, got %v", value.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// parseServiceRef parses a reference like "redis" or "infra:redis".
|
||||
func parseServiceRef(ref string) ServiceRef {
|
||||
parts := strings.SplitN(ref, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return ServiceRef{NodeID: parts[0], Name: parts[1]}
|
||||
}
|
||||
return ServiceRef{Name: parts[0]}
|
||||
}
|
||||
|
||||
// ParseYAML parses a node/service dependency graph from YAML bytes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// nodes:
|
||||
// server1:
|
||||
// services:
|
||||
// web:
|
||||
// agent_id: agent-1
|
||||
// depends_on:
|
||||
// - redis
|
||||
// - infra:cache
|
||||
// api:
|
||||
// depends_on:
|
||||
// redis:
|
||||
// condition: healthy
|
||||
// infra:
|
||||
// services:
|
||||
// cache:
|
||||
// db:
|
||||
func ParseYAML(data []byte) (*Graph, error) {
|
||||
var raw struct {
|
||||
Nodes map[string]yamlNode `yaml:"nodes"`
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse yaml: %w", err)
|
||||
}
|
||||
|
||||
g := New()
|
||||
|
||||
// Phase 1: register all nodes and services
|
||||
for nodeID, yn := range raw.Nodes {
|
||||
g.AddNode(nodeID)
|
||||
for svcName := range yn.Services {
|
||||
g.AddService(nodeID, &Service{Name: svcName})
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: wire dependencies
|
||||
for nodeID, yn := range raw.Nodes {
|
||||
for svcName, ys := range yn.Services {
|
||||
// Short form
|
||||
for _, ref := range ys.DependsOn.simple {
|
||||
target := parseServiceRef(ref)
|
||||
if err := g.AddDependency(nodeID, svcName, Dependency{
|
||||
Target: target,
|
||||
Condition: Started,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Long form
|
||||
for ref, cond := range ys.DependsOn.detail {
|
||||
target := parseServiceRef(ref)
|
||||
if err := g.AddDependency(nodeID, svcName, Dependency{
|
||||
Target: target,
|
||||
Condition: cond.Condition,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// ParseYAMLFile reads and parses from a file.
|
||||
func ParseYAMLFile(path string) (*Graph, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseYAML(data)
|
||||
}
|
||||
@@ -157,3 +157,8 @@ func (c *Collector) GetAgent(name string) (*Agent, bool) {
|
||||
func (c *Collector) Agents() []*Agent {
|
||||
return c.tracker.Agents()
|
||||
}
|
||||
|
||||
// GetSystemMetrics delegates to the tracker.
|
||||
func (c *Collector) GetSystemMetrics() map[string]AgentMetricsInfo {
|
||||
return c.tracker.GetSystemMetrics()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||
"google.golang.org/grpc/metadata"
|
||||
@@ -23,6 +24,9 @@ func (c *Collector) ReportServices(ctx context.Context, req *proto.ServicesUpdat
|
||||
}
|
||||
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})
|
||||
@@ -36,3 +40,53 @@ func (c *Collector) ReportServices(ctx context.Context, req *proto.ServicesUpdat
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,15 +97,69 @@ func (t *ConnTracker) UpdateServices(id string, services []Service) bool {
|
||||
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
|
||||
ConnectedAt time.Time
|
||||
ID string
|
||||
Label string
|
||||
Services []Service
|
||||
SystemMetrics SystemMetrics
|
||||
ConnectedAt time.Time
|
||||
}
|
||||
|
||||
@@ -51,3 +51,44 @@ func (ag *AgentsGroup) List(c *gin.Context) {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -51,11 +50,6 @@ type JobResult struct {
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
|
||||
// WaitJobIn is the request body for waiting on a job.
|
||||
type WaitJobIn struct {
|
||||
AgentID string `json:"agent_id" binding:"required"`
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -72,46 +66,60 @@ func (h *JobsHandlers) AddJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agent, ok := h.tracker.GetAgent(in.AgentID)
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
c.Error(fmt.Errorf("agent not found"))
|
||||
return
|
||||
}
|
||||
|
||||
command, err := resolveCommand(c, h.svc, in.InterpreterID, in.Command)
|
||||
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: command,
|
||||
Stdin: in.Stdin,
|
||||
Command: cmd,
|
||||
Stdin: stdin,
|
||||
})
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", h.whereami, jid)
|
||||
|
||||
c.JSON(http.StatusCreated, AddJobOut{
|
||||
return &AddJobOut{
|
||||
ID: jid,
|
||||
Command: command,
|
||||
Command: cmd,
|
||||
WaitURL: waitURL,
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WaitJob waits for a submitted job to complete (long-poll).
|
||||
// If the job is already done, returns immediately.
|
||||
// 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"
|
||||
// @Param body body WaitJobIn true "Agent reference"
|
||||
// @Success 200 {object} JobResult
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
@@ -123,32 +131,50 @@ func (h *JobsHandlers) WaitJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var in WaitJobIn
|
||||
if err := c.Bind(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
// Check database first
|
||||
job, err := h.jobRepo.GetJobByID(c.Request.Context(), jid)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
||||
return
|
||||
}
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
agent, ok := h.tracker.GetAgent(in.AgentID)
|
||||
// If job is already completed (has output or non-zero status), return immediately
|
||||
if job.Status != nil || job.Stdout != nil || job.Stderr != nil {
|
||||
c.JSON(http.StatusOK, JobResult{
|
||||
ID: job.ID,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
Stdout: *job.Stdout,
|
||||
Stderr: *job.Stderr,
|
||||
Status: *job.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Job is still pending — wait on the agent
|
||||
agent, ok := h.tracker.GetAgent(job.AgentID)
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
c.Error(fmt.Errorf("agent not found"))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
job, err := agent.WaitJob(jid)
|
||||
ajob, err := agent.WaitJob(jid)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, JobResult{
|
||||
ID: job.ID,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
Stdout: job.Stdout,
|
||||
Stderr: job.Stderr,
|
||||
Status: job.Status,
|
||||
ID: ajob.ID,
|
||||
Command: ajob.Command,
|
||||
Stdin: ajob.Stdin,
|
||||
Stdout: *ajob.Stdout,
|
||||
Stderr: *ajob.Stderr,
|
||||
Status: *ajob.Status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,42 +191,6 @@ func resolveCommand(c *gin.Context, svc *service.ScriptService, interpID int64,
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// @Summary Check command path
|
||||
// @Description Validates that a command binary exists on the system
|
||||
// @Tags jobs
|
||||
// @Accept json
|
||||
// @Param body body CheckCmdIn true "Command to check"
|
||||
// @Success 200 {object} CheckCmdOut
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /jobs/check_cmd [post]
|
||||
func (h *JobsHandlers) CheckCmd(c *gin.Context) {
|
||||
var in struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
}
|
||||
if err := c.Bind(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath(in.Command); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "command not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, CheckCmdOut{Exists: true})
|
||||
}
|
||||
|
||||
type CheckCmdIn struct {
|
||||
Command string `json:"command" binding:"required" example:"bash"`
|
||||
}
|
||||
type CheckCmdOut struct {
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
// JobMetricsOut is the response body for the job metrics endpoint.
|
||||
type JobMetricsOut struct {
|
||||
Total int `json:"total"`
|
||||
@@ -216,6 +206,7 @@ type JobMetricsOut struct {
|
||||
// @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
|
||||
@@ -228,8 +219,9 @@ func (h *JobsHandlers) GetJobMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Query("agent_id")
|
||||
since := time.Now().Add(-period)
|
||||
metrics, err := h.jobRepo.GetJobMetrics(c.Request.Context(), since)
|
||||
metrics, err := h.jobRepo.GetJobMetrics(c.Request.Context(), since, agentID)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
)
|
||||
|
||||
type ScriptHandlers struct {
|
||||
svc *service.ScriptService
|
||||
tracker *commander.ConnTracker
|
||||
svc *service.ScriptService
|
||||
tracker *commander.ConnTracker
|
||||
whereami string
|
||||
}
|
||||
|
||||
func NewScriptHandlers(svc *service.ScriptService, tracker *commander.ConnTracker) ScriptHandlers {
|
||||
return ScriptHandlers{svc: svc, tracker: tracker}
|
||||
func NewScriptHandlers(svc *service.ScriptService, tracker *commander.ConnTracker, whereami string) ScriptHandlers {
|
||||
return ScriptHandlers{svc: svc, tracker: tracker, whereami: whereami}
|
||||
}
|
||||
|
||||
type RunScriptIn struct {
|
||||
@@ -28,73 +29,52 @@ type RunScriptIn struct {
|
||||
Stdin *string `json:"stdin"`
|
||||
}
|
||||
|
||||
type RunScriptOut 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"`
|
||||
}
|
||||
|
||||
// RunScript executes a script on a target agent.
|
||||
// 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} RunScriptOut
|
||||
// @Success 201 {object} AddJobOut
|
||||
// @Security Bearer
|
||||
// @Router /scripts/run [post]
|
||||
func (h *ScriptHandlers) RunScript(c *gin.Context) {
|
||||
err := func() error {
|
||||
var in RunScriptIn
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
var in RunScriptIn
|
||||
if err := c.Bind(&in); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
command, err := h.svc.ResolveCommand(
|
||||
c.Request.Context(),
|
||||
in.InterpreterID,
|
||||
in.ScriptText,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agent, ok := h.tracker.GetAgent(in.AgentID)
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
c.Error(fmt.Errorf("agent not found"))
|
||||
return
|
||||
}
|
||||
|
||||
agent, ok := h.tracker.GetAgent(in.AgentID)
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
return fmt.Errorf("agent not found")
|
||||
}
|
||||
|
||||
jid, err := agent.AddJob(models.JobForInsert{
|
||||
Command: command,
|
||||
Stdin: in.Stdin,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job, err := agent.WaitJob(jid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, RunScriptOut{
|
||||
ID: job.ID,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
Stdout: job.Stdout,
|
||||
Stderr: job.Stderr,
|
||||
Status: job.Status,
|
||||
})
|
||||
return nil
|
||||
}()
|
||||
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.
|
||||
|
||||
@@ -16,13 +16,14 @@ import (
|
||||
|
||||
// ScriptHandlersGroup handles script management routes.
|
||||
type ScriptHandlersGroup struct {
|
||||
svc *service.ScriptService
|
||||
cmder *commander.Commander
|
||||
svc *service.ScriptService
|
||||
cmder *commander.Commander
|
||||
whereami string
|
||||
}
|
||||
|
||||
// NewScriptHandlersGroup creates a new ScriptHandlersGroup.
|
||||
func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander) *ScriptHandlersGroup {
|
||||
return &ScriptHandlersGroup{svc: svc, cmder: cmder}
|
||||
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.
|
||||
@@ -192,13 +193,13 @@ func (sh *ScriptHandlersGroup) DeleteScript(c *gin.Context) {
|
||||
|
||||
// RunScriptByID executes a stored script on a target agent.
|
||||
// @Summary Run script by ID
|
||||
// @Description Loads a script from storage, resolves interpreter command, and executes on the specified agent
|
||||
// @Description Loads a script from storage, resolves interpreter command, and submits it to the agent
|
||||
// @Tags scripts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Script ID"
|
||||
// @Param body body RunStoredScriptIn true "Agent token and optional stdin"
|
||||
// @Success 201 {object} RunScriptOut
|
||||
// @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
|
||||
@@ -248,19 +249,12 @@ func (sh *ScriptHandlersGroup) RunScriptByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
job, err := agent.WaitJob(jid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("job execution failed: %v", err)})
|
||||
return
|
||||
}
|
||||
waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", sh.whereami, jid)
|
||||
|
||||
c.JSON(http.StatusCreated, RunScriptOut{
|
||||
ID: job.ID,
|
||||
Command: job.Command,
|
||||
Stdin: job.Stdin,
|
||||
Stdout: job.Stdout,
|
||||
Stderr: job.Stderr,
|
||||
Status: job.Status,
|
||||
c.JSON(http.StatusCreated, AddJobOut{
|
||||
ID: jid,
|
||||
Command: command,
|
||||
WaitURL: waitURL,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -87,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
|
||||
@@ -113,18 +113,22 @@ type JobMetrics struct {
|
||||
}
|
||||
|
||||
// GetJobMetrics returns job success metrics for jobs updated since the given time.
|
||||
// A successful job has status == 0, failed has status != 0, pending has status == 0 with empty stdout/stderr.
|
||||
func (r *JobRepository) GetJobMetrics(ctx context.Context, since time.Time) (JobMetrics, error) {
|
||||
// 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
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`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 >= ?`,
|
||||
since,
|
||||
).Scan(&m.Total, &m.Success, &m.Failed, &m.Pending)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -81,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);
|
||||
`
|
||||
|
||||
@@ -49,5 +49,12 @@ func Open(path string) (*sql.DB, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Backend API URL. По умолчанию "/api/v1" (через nginx proxy).
|
||||
# Для локальной разработки: "http://localhost:8080/api/v1"
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
@@ -7,7 +7,9 @@
|
||||
"Bash(type *)",
|
||||
"Bash(dir)",
|
||||
"Bash(move *)",
|
||||
"Bash(findstr *)"
|
||||
"Bash(findstr *)",
|
||||
"Bash(del *)",
|
||||
"Bash(mkdir *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
|
||||
@@ -2,6 +2,9 @@ FROM node:25-alpine3.23 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_BASE_URL=/api/v1
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
+2
-4
@@ -9,15 +9,13 @@ server {
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Generated
+7152
File diff suppressed because it is too large
Load Diff
@@ -11,19 +11,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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": {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
FaUsers,
|
||||
FaShieldAlt,
|
||||
FaSpinner,
|
||||
FaExclamationCircle,
|
||||
FaPlus,
|
||||
} from "react-icons/fa";
|
||||
import { useAdminStore } from "./store/useAdminStore";
|
||||
import { UserCard } from "./components/UserCard";
|
||||
import { CreateUserModal } from "./components/CreateUserModal";
|
||||
|
||||
export const AdminPanel: React.FC = () => {
|
||||
const users = useAdminStore((s) => s.users);
|
||||
const loading = useAdminStore((s) => s.loading);
|
||||
const error = useAdminStore((s) => s.error);
|
||||
const fetchUsers = useAdminStore((s) => s.fetchUsers);
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const activeCount = users.filter((u) => u.is_active).length;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px", maxWidth: "900px", margin: "0 auto" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "var(--accent)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<FaShieldAlt size={18} style={{ color: "var(--accent-text)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Управление пользователями
|
||||
</h1>
|
||||
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||
{loading
|
||||
? "Загрузка..."
|
||||
: `${activeCount} / ${users.length} активных`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--accent-text)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<FaPlus size={12} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "12px",
|
||||
backgroundColor: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: "8px",
|
||||
color: "var(--error-text, #ef4444)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<FaExclamationCircle />
|
||||
<span style={{ fontSize: "13px" }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && users.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "60px 0",
|
||||
}}
|
||||
>
|
||||
<FaSpinner
|
||||
className="animate-spin"
|
||||
size={24}
|
||||
style={{ color: "var(--accent)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users list */}
|
||||
{!loading && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && users.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px 0",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: "14px" }}>
|
||||
Нет зарегистрированных пользователей
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create user modal */}
|
||||
<CreateUserModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { apiClient } from "@/shared/api/axios.instance";
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const raw = localStorage.getItem("auth-storage");
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.state?.token) return `bearer ${parsed.state.token}`;
|
||||
} catch {}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export interface AdminUserDto {
|
||||
id: number;
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
password: string;
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionsPayload {
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getUsers: async (): Promise<AdminUserDto[]> => {
|
||||
const res = await apiClient.get<AdminUserDto[]>("/auth/tokens", {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createUser: async (payload: CreateUserPayload): Promise<void> => {
|
||||
await apiClient.post("/auth/token", payload, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
},
|
||||
|
||||
deleteUser: async (login: string): Promise<void> => {
|
||||
await apiClient.delete(`/auth/tokens/${login}`, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
},
|
||||
|
||||
activateUser: async (login: string): Promise<void> => {
|
||||
await apiClient.post(
|
||||
`/auth/users/${login}/activate`,
|
||||
{},
|
||||
{ headers: { Authorization: getAuthHeader() } },
|
||||
);
|
||||
},
|
||||
|
||||
deactivateUser: async (login: string): Promise<void> => {
|
||||
await apiClient.post(
|
||||
`/auth/users/${login}/deactivate`,
|
||||
{},
|
||||
{ headers: { Authorization: getAuthHeader() } },
|
||||
);
|
||||
},
|
||||
|
||||
updatePermissions: async (
|
||||
login: string,
|
||||
payload: PermissionsPayload,
|
||||
): Promise<void> => {
|
||||
await apiClient.put(`/auth/users/${login}/permissions`, payload, {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
},
|
||||
|
||||
generateToken: async (label: string): Promise<string> => {
|
||||
const res = await apiClient.post<{ token: string }>(
|
||||
"/agents/register-token",
|
||||
{ label },
|
||||
{ headers: { Authorization: getAuthHeader() } },
|
||||
);
|
||||
return res.data.token;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useState } from "react";
|
||||
import { FaTimes, FaPlus } from "react-icons/fa";
|
||||
import { useAdminStore } from "../store/useAdminStore";
|
||||
|
||||
interface CreateUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const createUser = useAdminStore((s) => s.createUser);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
login: "",
|
||||
name: "",
|
||||
last_name: "",
|
||||
password: "",
|
||||
is_active: true,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: false,
|
||||
permission_view: true,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.login || !form.password) return;
|
||||
setLoading(true);
|
||||
await createUser(form);
|
||||
setLoading(false);
|
||||
setForm({
|
||||
login: "",
|
||||
name: "",
|
||||
last_name: "",
|
||||
password: "",
|
||||
is_active: true,
|
||||
permission_admin: false,
|
||||
permission_manage_agent: false,
|
||||
permission_view: true,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
minWidth: "380px",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "16px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Создать пользователя
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
<FaTimes size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{/* Login */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name + Last name */}
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.last_name}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, last_name: e.target.value })
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div style={{ paddingTop: "8px" }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "8px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Разрешения
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
|
||||
{[
|
||||
{ key: "is_active", label: "Active" },
|
||||
{ key: "permission_view", label: "View" },
|
||||
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||
{ key: "permission_admin", label: "Admin" },
|
||||
].map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
form[key as keyof typeof form] as boolean
|
||||
}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, [key]: e.target.checked })
|
||||
}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !form.login || !form.password}
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
padding: "10px",
|
||||
backgroundColor:
|
||||
loading || !form.login || !form.password
|
||||
? "var(--bg-secondary)"
|
||||
: "var(--accent)",
|
||||
color:
|
||||
loading || !form.login || !form.password
|
||||
? "var(--text-muted)"
|
||||
: "var(--accent-text)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor:
|
||||
loading || !form.login || !form.password
|
||||
? "default"
|
||||
: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<FaPlus size={12} />
|
||||
{loading ? "Создание..." : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from "react";
|
||||
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
|
||||
import type { AdminUser, PermissionKey } from "../types";
|
||||
import { useAdminStore } from "../store/useAdminStore";
|
||||
|
||||
interface UserCardProps {
|
||||
user: AdminUser;
|
||||
}
|
||||
|
||||
const permissions: { key: PermissionKey; label: string }[] = [
|
||||
{ key: "permission_view", label: "View" },
|
||||
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||
{ key: "permission_admin", label: "Admin" },
|
||||
];
|
||||
|
||||
export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||
const users = useAdminStore((s) => s.users);
|
||||
const toggleActive = useAdminStore((s) => s.toggleActive);
|
||||
const togglePermission = useAdminStore((s) => s.togglePermission);
|
||||
const deleteUser = useAdminStore((s) => s.deleteUser);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
transition: "all 0.2s",
|
||||
opacity: user.is_active ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
{/* Header: User info + Active toggle + Delete */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: user.is_active
|
||||
? "var(--accent)"
|
||||
: "var(--text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<FaUser size={16} style={{ color: "var(--card-bg)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{user.name} {user.last_name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{user.login}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
{/* Active toggle */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: user.is_active
|
||||
? "var(--success-text, #22c55e)"
|
||||
: "var(--error-text, #ef4444)",
|
||||
}}
|
||||
>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleActive(user.id, user.login, user.is_active)}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "22px",
|
||||
borderRadius: "11px",
|
||||
border: "none",
|
||||
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fff",
|
||||
position: "absolute",
|
||||
top: "3px",
|
||||
left: user.is_active ? "21px" : "3px",
|
||||
transition: "left 0.2s",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Удалить пользователя "${user.login}"?`)) {
|
||||
deleteUser(user.id, user.login);
|
||||
}
|
||||
}}
|
||||
title="Удалить"
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "1px solid transparent",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
padding: "6px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--error-text, #ef4444)";
|
||||
e.currentTarget.style.backgroundColor = "rgba(239,68,68,0.1)";
|
||||
e.currentTarget.style.borderColor = "rgba(239,68,68,0.3)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<FaTrash size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "16px",
|
||||
paddingTop: "12px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{permissions.map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => togglePermission(user.id, user.login, key, users)}
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid",
|
||||
borderColor: user[key] ? "var(--accent)" : "var(--border)",
|
||||
backgroundColor: user[key] ? "var(--accent)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.15s",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{user[key] && (
|
||||
<FaCheck
|
||||
size={10}
|
||||
style={{ color: "var(--accent-text, #fff)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AdminPanel } from "./AdminPanel";
|
||||
export { useAdminStore } from "./store/useAdminStore";
|
||||
export { adminApi } from "./api/admin.api";
|
||||
export type { AdminUser } from "./types";
|
||||
@@ -0,0 +1,129 @@
|
||||
import { create } from "zustand";
|
||||
import type { AdminUser, PermissionKey } from "../types";
|
||||
import { adminApi } from "../api/admin.api";
|
||||
import type { CreateUserPayload } from "../api/admin.api";
|
||||
|
||||
interface AdminState {
|
||||
users: AdminUser[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchUsers: () => Promise<void>;
|
||||
createUser: (payload: CreateUserPayload) => Promise<void>;
|
||||
deleteUser: (id: string, login: string) => Promise<void>;
|
||||
toggleActive: (id: string, login: string, current: boolean) => Promise<void>;
|
||||
togglePermission: (
|
||||
id: string,
|
||||
login: string,
|
||||
permission: PermissionKey,
|
||||
users: AdminUser[],
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAdminStore = create<AdminState>((set, get) => ({
|
||||
users: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchUsers: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await adminApi.getUsers();
|
||||
set({
|
||||
users: data.map((u) => ({
|
||||
id: String(u.id),
|
||||
login: u.login,
|
||||
name: u.name,
|
||||
last_name: u.last_name,
|
||||
is_active: u.is_active,
|
||||
permission_admin: u.permission_admin,
|
||||
permission_manage_agent: u.permission_manage_agent,
|
||||
permission_view: u.permission_view,
|
||||
})),
|
||||
loading: false,
|
||||
});
|
||||
} catch (e) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : "Failed to fetch users",
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createUser: async (payload) => {
|
||||
try {
|
||||
await adminApi.createUser(payload);
|
||||
await get().fetchUsers();
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "Failed to create user" });
|
||||
}
|
||||
},
|
||||
|
||||
deleteUser: async (id, login) => {
|
||||
try {
|
||||
await adminApi.deleteUser(login);
|
||||
set((state) => ({
|
||||
users: state.users.filter((u) => u.id !== id),
|
||||
}));
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "Failed to delete user" });
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive: async (id, login, current) => {
|
||||
try {
|
||||
if (current) {
|
||||
await adminApi.deactivateUser(login);
|
||||
} else {
|
||||
await adminApi.activateUser(login);
|
||||
}
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.id === id ? { ...u, is_active: !current } : u,
|
||||
),
|
||||
}));
|
||||
} catch (e) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : "Failed to toggle active",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
togglePermission: async (id, login, permission, users) => {
|
||||
const user = users.find((u) => u.id === id);
|
||||
if (!user) return;
|
||||
|
||||
const newPermissions = {
|
||||
is_active: user.is_active,
|
||||
permission_admin:
|
||||
permission === "permission_admin"
|
||||
? !user.permission_admin
|
||||
: user.permission_admin,
|
||||
permission_manage_agent:
|
||||
permission === "permission_manage_agent"
|
||||
? !user.permission_manage_agent
|
||||
: user.permission_manage_agent,
|
||||
permission_view:
|
||||
permission === "permission_view"
|
||||
? !user.permission_view
|
||||
: user.permission_view,
|
||||
};
|
||||
|
||||
try {
|
||||
await adminApi.updatePermissions(login, newPermissions);
|
||||
set((state) => ({
|
||||
users: state.users.map((u) =>
|
||||
u.id === id
|
||||
? {
|
||||
...u,
|
||||
[permission]: !u[permission],
|
||||
}
|
||||
: u,
|
||||
),
|
||||
}));
|
||||
} catch (e) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : "Failed to update permissions",
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
is_active: boolean;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
}
|
||||
|
||||
export type PermissionKey =
|
||||
| "permission_admin"
|
||||
| "permission_manage_agent"
|
||||
| "permission_view";
|
||||
@@ -0,0 +1,181 @@
|
||||
import { apiClient } from "@/shared/api/axios.instance";
|
||||
import type {
|
||||
AgentInfo,
|
||||
TokenCreate,
|
||||
TokenUser,
|
||||
LogEntry,
|
||||
LogFilters,
|
||||
InsertLogRequest,
|
||||
InsertLogsRequest,
|
||||
TokenUpdate,
|
||||
TokenUpdatePermissions,
|
||||
TokenPasswordReset,
|
||||
RegistrationRequest,
|
||||
DeployAgentsRequest,
|
||||
DeployResponse,
|
||||
SystemMetrics,
|
||||
} from "../types/agent.types";
|
||||
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||
|
||||
class AgentApiService {
|
||||
private readonly basePath = "/agents";
|
||||
private readonly authBasePath = "/auth";
|
||||
private readonly logsBasePath = "/logs";
|
||||
|
||||
async getAgents(): Promise<AgentInfo[]> {
|
||||
const response = await apiClient.get<AgentInfo[]>(this.basePath);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async getUsers(): Promise<TokenUser[]> {
|
||||
const response = await apiClient.get<TokenUser[]>(
|
||||
`${this.authBasePath}/tokens`,
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async createUser(data: TokenCreate): Promise<void> {
|
||||
await apiClient.post(`${this.authBasePath}/token`, data);
|
||||
}
|
||||
|
||||
async deleteUser(login: string): Promise<void> {
|
||||
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
|
||||
}
|
||||
|
||||
async deleteMyAccount(): Promise<void> {
|
||||
await apiClient.delete(`${this.authBasePath}/token`);
|
||||
}
|
||||
|
||||
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
|
||||
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
|
||||
params: {
|
||||
level: filters?.level || undefined,
|
||||
service: filters?.service || undefined,
|
||||
agent: filters?.agent || undefined,
|
||||
date_from: filters?.date_from || undefined,
|
||||
date_to: filters?.date_to || undefined,
|
||||
limit: filters?.limit ?? 100,
|
||||
offset: filters?.offset ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
console.error(
|
||||
"[Logs] Unexpected response format:",
|
||||
typeof response.data,
|
||||
response.data,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async insertLog(entry: InsertLogRequest): Promise<void> {
|
||||
await apiClient.post(this.logsBasePath, entry);
|
||||
}
|
||||
|
||||
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
|
||||
await apiClient.post(`${this.logsBasePath}/batch`, data);
|
||||
}
|
||||
|
||||
async getDistinctAgents(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>(
|
||||
`${this.logsBasePath}/agents`,
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async getDistinctLevels(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>(
|
||||
`${this.logsBasePath}/levels`,
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async getDistinctServices(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>(
|
||||
`${this.logsBasePath}/services`,
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
// User management methods
|
||||
async getUserByLogin(login: string): Promise<TokenUser> {
|
||||
const response = await apiClient.get<TokenUser>(
|
||||
`${this.authBasePath}/users/${login}`,
|
||||
);
|
||||
if (!response.data || typeof response.data !== "object") {
|
||||
throw new Error(`User not found: ${login}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getInactiveUsers(): Promise<TokenUser[]> {
|
||||
const response = await apiClient.get<TokenUser[]>(
|
||||
`${this.authBasePath}/users/inactive`,
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async updateUser(login: string, data: TokenUpdate): Promise<void> {
|
||||
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
|
||||
}
|
||||
|
||||
async updateUserPermissions(
|
||||
login: string,
|
||||
data: TokenUpdatePermissions,
|
||||
): Promise<void> {
|
||||
await apiClient.put(
|
||||
`${this.authBasePath}/users/${login}/permissions`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
async resetUserPassword(
|
||||
login: string,
|
||||
data: TokenPasswordReset,
|
||||
): Promise<void> {
|
||||
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
|
||||
}
|
||||
|
||||
async activateUser(login: string): Promise<void> {
|
||||
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
|
||||
}
|
||||
|
||||
async deactivateUser(login: string): Promise<void> {
|
||||
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
|
||||
}
|
||||
|
||||
async createRegistrationToken(
|
||||
data: RegistrationRequest,
|
||||
): Promise<Record<string, string>> {
|
||||
const response = await apiClient.post<Record<string, string>>(
|
||||
`${this.basePath}/register-token`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
|
||||
const response = await apiClient.post<DeployResponse>(
|
||||
`${this.basePath}/deploy`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getSystemMetrics(): Promise<SystemMetrics[]> {
|
||||
const response = await apiClient.get<SystemMetrics[]>(
|
||||
`${this.basePath}/system-metrics`,
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
|
||||
async getGraph(): Promise<GraphApiResponse> {
|
||||
const response = await apiClient.get<GraphApiResponse>("/graph");
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentApiService = new AgentApiService();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { agentApiService } from "../api/agent.api.service";
|
||||
import type { AgentInfo } from "../types/agent.types";
|
||||
|
||||
interface UseAgentsResult {
|
||||
agents: AgentInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAgents(): UseAgentsResult {
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await agentApiService.getAgents();
|
||||
setAgents(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch agents";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
|
||||
return { agents, isLoading, error, refetch: fetchAgents };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export { SSHAgentForm } from "./ui/SSHAgentForm";
|
||||
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
|
||||
|
||||
export { useAgents } from "./hooks/useAgents.hook";
|
||||
|
||||
export { agentApiService } from "./api/agent.api.service";
|
||||
|
||||
export type {
|
||||
AgentInfo,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
TokenCreate,
|
||||
TokenUser,
|
||||
LogEntry,
|
||||
InsertLogRequest,
|
||||
InsertLogsRequest,
|
||||
LogFilters,
|
||||
TokenUpdate,
|
||||
TokenUpdatePermissions,
|
||||
TokenPasswordReset,
|
||||
RegistrationRequest,
|
||||
DeployResult,
|
||||
DeployAgentsRequest,
|
||||
AgentDeployConfig,
|
||||
DeployResponse,
|
||||
} from "./types/agent.types";
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type LogLevel = "info" | "warning" | "error" | "fatal";
|
||||
|
||||
interface LogFilterState {
|
||||
searchQuery: string;
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
selectedLogLevel: LogLevel | null;
|
||||
selectedService: string;
|
||||
selectedAgent: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
setSearchQuery: (query: string) => void;
|
||||
setStartDate: (date: Date | null) => void;
|
||||
setEndDate: (date: Date | null) => void;
|
||||
setSelectedLogLevel: (level: LogLevel | null) => void;
|
||||
setSelectedService: (service: string) => void;
|
||||
setSelectedAgent: (agent: string) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
setOffset: (offset: number) => void;
|
||||
resetFilters: () => void;
|
||||
getFilters: () => {
|
||||
level?: string;
|
||||
service?: string;
|
||||
agent?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||
searchQuery: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
selectedLogLevel: null,
|
||||
selectedService: "",
|
||||
selectedAgent: "",
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setStartDate: (date) => set({ startDate: date }),
|
||||
setEndDate: (date) => set({ endDate: date }),
|
||||
setSelectedLogLevel: (level) => set({ selectedLogLevel: level }),
|
||||
setSelectedService: (service) => set({ selectedService: service }),
|
||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||
setLimit: (limit) => set({ limit }),
|
||||
setOffset: (offset) => set({ offset }),
|
||||
|
||||
resetFilters: () => {
|
||||
set({
|
||||
searchQuery: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
selectedLogLevel: null,
|
||||
selectedService: "",
|
||||
selectedAgent: "",
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
|
||||
getFilters: () => {
|
||||
const {
|
||||
selectedLogLevel,
|
||||
selectedService,
|
||||
selectedAgent,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
offset,
|
||||
} = get();
|
||||
return {
|
||||
level: selectedLogLevel || undefined,
|
||||
service: selectedService || undefined,
|
||||
agent: selectedAgent || undefined,
|
||||
date_from: startDate ? startDate.toISOString() : undefined,
|
||||
date_to: endDate ? endDate.toISOString() : undefined,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,131 @@
|
||||
export interface AgentInfo {
|
||||
token: string;
|
||||
label: string;
|
||||
services: string[];
|
||||
connected_at: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
last_name: string;
|
||||
login: string;
|
||||
name: string;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface TokenCreate {
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
password: string;
|
||||
permission_admin?: boolean;
|
||||
permission_manage_agent?: boolean;
|
||||
permission_view?: boolean;
|
||||
}
|
||||
|
||||
export interface TokenUser {
|
||||
id: number;
|
||||
login: string;
|
||||
name: string;
|
||||
last_name: string;
|
||||
permission_admin: boolean;
|
||||
permission_manage_agent: boolean;
|
||||
permission_view: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
Agent: string;
|
||||
Level: string;
|
||||
Message: string;
|
||||
Service: string;
|
||||
Timestamp: string;
|
||||
}
|
||||
|
||||
export interface InsertLogRequest {
|
||||
agent: string;
|
||||
level: string;
|
||||
message: string;
|
||||
service: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface InsertLogsRequest {
|
||||
logs: InsertLogRequest[];
|
||||
}
|
||||
|
||||
export interface LogFilters {
|
||||
level?: string | string[];
|
||||
service?: string;
|
||||
agent?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface TokenUpdate {
|
||||
name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface TokenUpdatePermissions {
|
||||
is_active?: boolean;
|
||||
permission_admin?: boolean;
|
||||
permission_manage_agent?: boolean;
|
||||
permission_view?: boolean;
|
||||
}
|
||||
|
||||
export interface TokenPasswordReset {
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface RegistrationRequest {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
agent_label: string;
|
||||
error?: string;
|
||||
ip: string;
|
||||
success: boolean;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface DeployAgentsRequest {
|
||||
servers: AgentDeployConfig[];
|
||||
}
|
||||
|
||||
export interface AgentDeployConfig {
|
||||
agentLabel: string;
|
||||
authMethod: "key" | "password";
|
||||
deployType: "docker" | "binary";
|
||||
ip: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
sshKey?: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface DeployResponse {
|
||||
message?: string;
|
||||
results: DeployResult[];
|
||||
}
|
||||
|
||||
export interface SystemMetrics {
|
||||
connected_at: string;
|
||||
cpu_percent: number;
|
||||
disk_percent: number;
|
||||
id: string;
|
||||
label: string;
|
||||
memory_percent: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
FiSearch,
|
||||
FiX,
|
||||
FiFilter,
|
||||
FiCalendar,
|
||||
FiTag,
|
||||
FiCheck,
|
||||
} from "react-icons/fi";
|
||||
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
|
||||
|
||||
const logLevelColors: Record<
|
||||
LogLevel,
|
||||
{ bg: string; text: string; border: string }
|
||||
> = {
|
||||
info: {
|
||||
bg: "rgba(59, 130, 246, 0.1)",
|
||||
text: "#3b82f6",
|
||||
border: "rgba(59, 130, 246, 0.3)",
|
||||
},
|
||||
warning: {
|
||||
bg: "rgba(245, 158, 11, 0.1)",
|
||||
text: "#f59e0b",
|
||||
border: "rgba(245, 158, 11, 0.3)",
|
||||
},
|
||||
error: {
|
||||
bg: "var(--error-bg)",
|
||||
text: "var(--error-text)",
|
||||
border: "var(--error-border)",
|
||||
},
|
||||
fatal: {
|
||||
bg: "rgba(168, 85, 247, 0.1)",
|
||||
text: "#a855f7",
|
||||
border: "rgba(168, 85, 247, 0.3)",
|
||||
},
|
||||
};
|
||||
|
||||
interface LogFiltersProps {
|
||||
onApply: () => void;
|
||||
availableServices: string[];
|
||||
availableAgents: string[];
|
||||
}
|
||||
|
||||
export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
onApply,
|
||||
availableServices,
|
||||
availableAgents,
|
||||
}) => {
|
||||
const {
|
||||
searchQuery,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedLogLevel,
|
||||
selectedService,
|
||||
selectedAgent,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setSelectedLogLevel,
|
||||
setSelectedService,
|
||||
setSelectedAgent,
|
||||
resetFilters,
|
||||
} = useLogFilterStore();
|
||||
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
|
||||
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
|
||||
const [localService, setLocalService] = useState(selectedService);
|
||||
const [localAgent, setLocalAgent] = useState(selectedAgent);
|
||||
const [localLevel, setLocalLevel] = useState<LogLevel | null>(
|
||||
selectedLogLevel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearchQuery(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalStartDate(startDate);
|
||||
}, [startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndDate(endDate);
|
||||
}, [endDate]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalService(selectedService);
|
||||
}, [selectedService]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAgent(selectedAgent);
|
||||
}, [selectedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalLevel(selectedLogLevel);
|
||||
}, [selectedLogLevel]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
setSearchQuery(localSearchQuery);
|
||||
setStartDate(localStartDate);
|
||||
setEndDate(localEndDate);
|
||||
setSelectedLogLevel(localLevel);
|
||||
setSelectedService(localService);
|
||||
setSelectedAgent(localAgent);
|
||||
onApply();
|
||||
}, [
|
||||
localSearchQuery,
|
||||
localStartDate,
|
||||
localEndDate,
|
||||
localLevel,
|
||||
localService,
|
||||
localAgent,
|
||||
onApply,
|
||||
]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setLocalSearchQuery("");
|
||||
setLocalStartDate(null);
|
||||
setLocalEndDate(null);
|
||||
setLocalLevel(null);
|
||||
setLocalService("");
|
||||
setLocalAgent("");
|
||||
resetFilters();
|
||||
onApply();
|
||||
}, [resetFilters, onApply]);
|
||||
|
||||
const getActiveFiltersCount = () => {
|
||||
let count = 0;
|
||||
if (searchQuery) count++;
|
||||
if (startDate) count++;
|
||||
if (endDate) count++;
|
||||
if (selectedService) count++;
|
||||
if (selectedAgent) count++;
|
||||
if (selectedLogLevel) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return null;
|
||||
return date.toLocaleDateString("ru-RU");
|
||||
};
|
||||
|
||||
const activeFiltersCount = getActiveFiltersCount();
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
...inputStyle,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FiFilter size={14} style={{ color: "var(--accent)" }} />
|
||||
<h3
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Фильтры логов
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
Активно: {activeFiltersCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<FiSearch
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={localSearchQuery}
|
||||
onChange={(e) => setLocalSearchQuery(e.target.value)}
|
||||
placeholder="Поиск по сообщению..."
|
||||
style={{ ...inputStyle, paddingLeft: "32px" }}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Service Select */}
|
||||
<select
|
||||
value={localService}
|
||||
onChange={(e) => setLocalService(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Все сервисы</option>
|
||||
{availableServices.map((service) => (
|
||||
<option key={service} value={service}>
|
||||
{service}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Agent Select */}
|
||||
<select
|
||||
value={localAgent}
|
||||
onChange={(e) => setLocalAgent(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Все агенты</option>
|
||||
{availableAgents.map((agent) => (
|
||||
<option key={agent} value={agent}>
|
||||
{agent}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={
|
||||
localStartDate ? localStartDate.toISOString().split("T")[0] : ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setLocalStartDate(
|
||||
e.target.value ? new Date(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
style={{ ...inputStyle, minWidth: 0 }}
|
||||
placeholder="Дата от"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={
|
||||
localEndDate ? localEndDate.toISOString().split("T")[0] : ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setLocalEndDate(
|
||||
e.target.value ? new Date(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
style={{ ...inputStyle, minWidth: 0 }}
|
||||
placeholder="Дата до"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Levels */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Уровень логов
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
|
||||
(level) => {
|
||||
const isSelected = localLevel === level;
|
||||
const colors = logLevelColors[level];
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => setLocalLevel(isSelected ? null : level)}
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium transition-all border flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isSelected ? colors.bg : "transparent",
|
||||
color: isSelected ? colors.text : "var(--text-secondary)",
|
||||
borderColor: isSelected ? colors.border : "var(--border)",
|
||||
minHeight: "36px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isSelected) {
|
||||
e.currentTarget.style.backgroundColor = colors.text;
|
||||
e.currentTarget.style.color = "#fff";
|
||||
} else {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"rgba(128, 128, 128, 0.08)";
|
||||
e.currentTarget.style.color = "var(--text-primary)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isSelected
|
||||
? colors.bg
|
||||
: "transparent";
|
||||
e.currentTarget.style.color = isSelected
|
||||
? colors.text
|
||||
: "var(--text-secondary)";
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<FiCheck size={10} className="inline mr-1" />
|
||||
)}
|
||||
{level.toUpperCase()}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--button-primary)",
|
||||
color: "var(--button-primary-text)",
|
||||
minHeight: "44px",
|
||||
}}
|
||||
>
|
||||
<FiCheck size={14} />
|
||||
Применить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium border"
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
minHeight: "44px",
|
||||
}}
|
||||
>
|
||||
<FiX size={14} />
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div
|
||||
className="mt-4 pt-4 border-t"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FiFilter size={10} style={{ color: "var(--accent)" }} />
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Активные фильтры:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{searchQuery && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<FiSearch size={10} />
|
||||
<span style={{ color: "var(--text-primary)" }}>
|
||||
Поиск: {searchQuery}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalSearchQuery("");
|
||||
setSearchQuery("");
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedService && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<FiTag size={10} />
|
||||
<span style={{ color: "var(--text-primary)" }}>
|
||||
Сервис: {selectedService}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalService("");
|
||||
setSelectedService("");
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLogLevel &&
|
||||
(() => {
|
||||
const colors = logLevelColors[selectedLogLevel];
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<FiTag size={10} style={{ color: colors.text }} />
|
||||
<span style={{ color: colors.text }}>
|
||||
Уровень: {selectedLogLevel.toUpperCase()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalLevel(null);
|
||||
setSelectedLogLevel(null);
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{selectedAgent && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<FiTag size={10} />
|
||||
<span style={{ color: "var(--text-primary)" }}>
|
||||
Агент: {selectedAgent}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalAgent("");
|
||||
setSelectedAgent("");
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{startDate && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<FiCalendar size={10} />
|
||||
<span style={{ color: "var(--text-primary)" }}>
|
||||
С: {formatDate(startDate)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalStartDate(null);
|
||||
setStartDate(null);
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{endDate && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<FiCalendar size={10} />
|
||||
<span style={{ color: "var(--text-primary)" }}>
|
||||
По: {formatDate(endDate)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalEndDate(null);
|
||||
setEndDate(null);
|
||||
onApply();
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<FiX size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FiPlus,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { apiClient } from "@/shared/api/axios.instance";
|
||||
import type { Interpreter } from "../types";
|
||||
|
||||
export interface ScriptNodeDto {
|
||||
id: number;
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
content?: string;
|
||||
children?: string[];
|
||||
interpreter_id?: number;
|
||||
}
|
||||
|
||||
export interface ScriptResponse {
|
||||
id: number;
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScriptPayload {
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface UpdateScriptPayload {
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RunScriptPayload {
|
||||
stdin?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface RunScriptResponse {
|
||||
command: string[];
|
||||
id: number;
|
||||
wait_url: string;
|
||||
}
|
||||
|
||||
export interface CreateInterpreterPayload {
|
||||
argv: string[];
|
||||
label: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface JobWaitResponse {
|
||||
command: string[];
|
||||
id: number;
|
||||
status: number;
|
||||
stderr: string;
|
||||
stdin: string;
|
||||
stdout: string;
|
||||
}
|
||||
|
||||
// apiClient уже имеет интерсептор для Authorization header
|
||||
export const scriptsApi = {
|
||||
getInterpreters: async (): Promise<Interpreter[]> => {
|
||||
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getTree: async (): Promise<ScriptNodeDto[]> => {
|
||||
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createScript: async (
|
||||
payload: CreateScriptPayload,
|
||||
): Promise<ScriptResponse> => {
|
||||
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
updateScript: async (
|
||||
id: number,
|
||||
payload: UpdateScriptPayload,
|
||||
): Promise<ScriptResponse> => {
|
||||
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
deleteScript: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`);
|
||||
},
|
||||
|
||||
createFolder: async (path: string): Promise<{ path: string }> => {
|
||||
const res = await apiClient.post<{ path: string }>("/scripts/folder", {
|
||||
path,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
deleteFolder: async (path: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/folder`, { data: { path } });
|
||||
},
|
||||
|
||||
rename: async (payload: {
|
||||
old_path: string;
|
||||
new_path: string;
|
||||
}): Promise<{ path: string }> => {
|
||||
const res = await apiClient.post<{ path: string }>(
|
||||
"/scripts/rename",
|
||||
payload,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
runScript: async (
|
||||
id: number,
|
||||
payload: RunScriptPayload,
|
||||
): Promise<RunScriptResponse> => {
|
||||
const res = await apiClient.post<RunScriptResponse>(
|
||||
`/scripts/${id}/run`,
|
||||
payload,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
waitJob: async (id: number): Promise<JobWaitResponse> => {
|
||||
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createInterpreter: async (
|
||||
payload: CreateInterpreterPayload,
|
||||
): Promise<Interpreter> => {
|
||||
const res = await apiClient.post<Interpreter>(
|
||||
"/scripts/interpreters",
|
||||
payload,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { MdClose, MdAdd } from "react-icons/md";
|
||||
import { scriptsApi } from "../api/scripts.api";
|
||||
import type { CreateInterpreterPayload } from "../api/scripts.api";
|
||||
|
||||
interface AddInterpreterModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [argv, setArgv] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !label.trim()) {
|
||||
setError("Name and Label are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload: CreateInterpreterPayload = {
|
||||
name: name.trim(),
|
||||
label: label.trim(),
|
||||
argv: argv
|
||||
.split(" ")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
await scriptsApi.createInterpreter(payload);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create interpreter:", e);
|
||||
setError(e?.response?.data?.detail || "Failed to create interpreter");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
width: "420px",
|
||||
maxWidth: "90vw",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Add Interpreter
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<MdClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Name <span style={{ color: "#f44747" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Python, Node.js, etc."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Label <span style={{ color: "#f44747" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="python3, node, etc."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Arguments <span style={{ color: "#858585" }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={argv}
|
||||
onChange={(e) => setArgv(e.target.value)}
|
||||
placeholder="-u -O (space separated)"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
||||
border: "1px solid #f44747",
|
||||
borderRadius: "4px",
|
||||
color: "#f44747",
|
||||
fontSize: "12px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
backgroundColor: loading ? "#555" : "#0e639c",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
color: "#ffffff",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⏳
|
||||
</span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MdAdd size={16} />
|
||||
Add Interpreter
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { FiFolder } from "react-icons/fi";
|
||||
import { getLanguage } from "../helpers/fileTree";
|
||||
|
||||
interface CodeEditorProps {
|
||||
filePath: string;
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
}
|
||||
|
||||
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
filePath,
|
||||
content,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#1e1e1e",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
{filePath ? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={getLanguage(filePath)}
|
||||
value={content}
|
||||
onChange={(value) => onChange(value || "")}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
|
||||
tabSize: 4,
|
||||
wordWrap: "on",
|
||||
lineNumbers: "on",
|
||||
automaticLayout: true,
|
||||
renderWhitespace: "selection",
|
||||
smoothScrolling: true,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: "#858585",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "24px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<FiFolder size={64} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
marginBottom: "12px",
|
||||
color: "#cccccc",
|
||||
}}
|
||||
>
|
||||
Welcome to Web VS Code
|
||||
</div>
|
||||
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
|
||||
Right-click on a folder to create files
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#0e639c" }}>
|
||||
Or right-click anywhere in the explorer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
|
||||
|
||||
const MenuItem: React.FC<{
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, danger, children }) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
color: danger ? "#f48771" : "#cccccc",
|
||||
fontSize: "13px",
|
||||
transition: "background-color 0.1s",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
onNewFile: () => void;
|
||||
onNewFolder: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
hasNode: boolean;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onRename,
|
||||
onDelete,
|
||||
hasNode,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleClick = () => onClose();
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: y,
|
||||
left: x,
|
||||
backgroundColor: "#252526",
|
||||
border: "1px solid #3e3e42",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||
zIndex: 1000,
|
||||
minWidth: "180px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={onNewFile}>
|
||||
<FiFile /> New File
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onNewFolder}>
|
||||
<FiFolder /> New Folder
|
||||
</MenuItem>
|
||||
{hasNode && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "#3e3e42",
|
||||
margin: "4px 0",
|
||||
}}
|
||||
/>
|
||||
<MenuItem onClick={onRename}>
|
||||
<FiEdit3 /> Rename
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onDelete} danger>
|
||||
<FiTrash2 /> Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
|
||||
import { GoKebabHorizontal } from "react-icons/go";
|
||||
import { MdClose, MdAdd } from "react-icons/md";
|
||||
import { FileTreeItem } from "./FileTreeItem";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { InputDialog } from "./InputDialog";
|
||||
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
|
||||
import { useIDEStore } from "../store/useIDEStore";
|
||||
import type { FileNode } from "../types";
|
||||
|
||||
interface FileExplorerProps {
|
||||
files: FileNode;
|
||||
onDeleteRoot: () => void;
|
||||
}
|
||||
|
||||
export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||
files,
|
||||
onDeleteRoot,
|
||||
}) => {
|
||||
const store = useIDEStore();
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Фокус на инпут при открытии поиска
|
||||
useEffect(() => {
|
||||
if (showSearch) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
}, [showSearch]);
|
||||
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
// Скрываем поиск при потере фокуса с небольшой задержкой,
|
||||
// чтобы клики по кнопке очистки успели сработать
|
||||
setTimeout(() => {
|
||||
if (
|
||||
searchInputRef.current &&
|
||||
!searchInputRef.current.contains(document.activeElement)
|
||||
) {
|
||||
setShowSearch(false);
|
||||
store.setSearchQuery("");
|
||||
}
|
||||
}, 100);
|
||||
}, [store]);
|
||||
|
||||
const handleEmptyContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Загружаем интерпретаторы перед открытием меню
|
||||
if (store.interpreters.length === 0) {
|
||||
store.fetchInterpreters();
|
||||
}
|
||||
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
|
||||
};
|
||||
|
||||
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||
};
|
||||
|
||||
// Загружаем интерпретаторы при монтировании компонента
|
||||
useEffect(() => {
|
||||
if (store.interpreters.length === 0) {
|
||||
store.fetchInterpreters();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredFiles = store.searchQuery
|
||||
? (files.children || [])
|
||||
.map((child) => filterTree(child, store.searchQuery))
|
||||
.filter((child): child is FileNode => child !== null)
|
||||
: files.children || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (store.searchQuery && files) {
|
||||
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
|
||||
if (pathsToExpand.size > 0) {
|
||||
store.autoExpandPaths(pathsToExpand);
|
||||
}
|
||||
}
|
||||
}, [store.searchQuery, files, store.autoExpandPaths]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#252526",
|
||||
}}
|
||||
onContextMenu={handleEmptyContextMenu}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "0 8px",
|
||||
height: "35px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid #3e3e42",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "#bbbbbb",
|
||||
fontWeight: 500,
|
||||
fontSize: "11px",
|
||||
letterSpacing: "0.8px",
|
||||
}}
|
||||
>
|
||||
EXPLORER
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!showSearch) {
|
||||
setShowSearch(true);
|
||||
} else {
|
||||
setShowSearch(false);
|
||||
store.setSearchQuery("");
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: showSearch ? "#cccccc" : "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
title="Search in files"
|
||||
>
|
||||
<FiSearch size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={store.collapseAllFolders}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
e.currentTarget.style.color = "#cccccc";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "#858585";
|
||||
}}
|
||||
title="Collapse All"
|
||||
>
|
||||
<FiMinus size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={store.expandAllFolders}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
e.currentTarget.style.color = "#cccccc";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "#858585";
|
||||
}}
|
||||
title="Expand All"
|
||||
>
|
||||
<GoKebabHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#3c3c3c",
|
||||
border: store.searchQuery
|
||||
? "1px solid #007acc"
|
||||
: "1px solid transparent",
|
||||
borderRadius: "4px",
|
||||
padding: "0 6px",
|
||||
transition: "border-color 0.1s",
|
||||
}}
|
||||
>
|
||||
<FiSearch size={13} color="#858585" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={store.searchQuery}
|
||||
onChange={(e) => store.setSearchQuery(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
placeholder="Search..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "5px 6px",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "#cccccc",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{store.searchQuery && (
|
||||
<button
|
||||
onClick={() => store.setSearchQuery("")}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "2px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<MdClose size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
{filteredFiles.length > 0 ? (
|
||||
filteredFiles.map((child, idx) => (
|
||||
<FileTreeItem
|
||||
key={idx}
|
||||
node={child}
|
||||
level={0}
|
||||
onFileSelect={store.selectFile}
|
||||
selectedFile={store.activeFile?.path || null}
|
||||
onContextMenu={handleNodeContextMenu}
|
||||
expandedFolders={store.expandedFolders}
|
||||
onToggleFolder={store.toggleFolder}
|
||||
onDelete={store.handleDeleteNode}
|
||||
searchQuery={store.searchQuery}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
color: "#858585",
|
||||
fontSize: "13px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{store.contextMenu && (
|
||||
<ContextMenu
|
||||
x={store.contextMenu.x}
|
||||
y={store.contextMenu.y}
|
||||
onClose={() => store.setContextMenu(null)}
|
||||
onNewFile={() => {
|
||||
store.setDialog({
|
||||
type: "newFile",
|
||||
node: store.contextMenu?.node || null,
|
||||
});
|
||||
store.setContextMenu(null);
|
||||
}}
|
||||
onNewFolder={() => {
|
||||
store.setDialog({
|
||||
type: "newFolder",
|
||||
node: store.contextMenu?.node || null,
|
||||
});
|
||||
store.setContextMenu(null);
|
||||
}}
|
||||
onRename={() => {
|
||||
store.setDialog({
|
||||
type: "rename",
|
||||
node: store.contextMenu?.node || null,
|
||||
});
|
||||
store.setContextMenu(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (store.contextMenu?.node) {
|
||||
store.handleDeleteNode(store.contextMenu.node);
|
||||
}
|
||||
store.setContextMenu(null);
|
||||
}}
|
||||
hasNode={!!store.contextMenu.node}
|
||||
/>
|
||||
)}
|
||||
|
||||
{store.dialog && (
|
||||
<InputDialog
|
||||
title={
|
||||
store.dialog.type === "newFile"
|
||||
? "New File"
|
||||
: store.dialog.type === "newFolder"
|
||||
? "New Folder"
|
||||
: "Rename"
|
||||
}
|
||||
initialValue={
|
||||
store.dialog.type === "rename" && store.dialog.node
|
||||
? store.dialog.node.name
|
||||
: ""
|
||||
}
|
||||
onConfirm={(value, interpreterId) => {
|
||||
store.handleDialogConfirm(value, interpreterId);
|
||||
}}
|
||||
onCancel={() => store.setDialog(null)}
|
||||
interpreters={
|
||||
store.dialog.type === "newFile" ? store.interpreters : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import type { FileNode } from "../types";
|
||||
import { FilePickerItem } from "./FilePickerItem";
|
||||
import { useFilePickerStore } from "../store/useFilePickerStore";
|
||||
import { TerminalOutput } from "@/modules/terminal";
|
||||
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
||||
|
||||
interface FilePickerProps {
|
||||
files: FileNode;
|
||||
onRun?: (path: string) => void;
|
||||
}
|
||||
|
||||
const FilePickerTree: React.FC<{
|
||||
node: FileNode;
|
||||
level: number;
|
||||
onRun?: (path: string) => void;
|
||||
}> = ({ node, level, onRun }) => {
|
||||
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
||||
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
||||
|
||||
const nodePath = node.path || node.name;
|
||||
const isExpanded = expandedFolders.has(nodePath);
|
||||
|
||||
if (node.type === "file") {
|
||||
return (
|
||||
<FilePickerItem
|
||||
name={node.name}
|
||||
type="file"
|
||||
path={nodePath}
|
||||
level={level}
|
||||
onRun={onRun}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilePickerItem
|
||||
name={node.name}
|
||||
type="folder"
|
||||
path={nodePath}
|
||||
isExpanded={isExpanded}
|
||||
level={level}
|
||||
onToggleFolder={toggleFolder}
|
||||
>
|
||||
{node.children?.map((child, idx) => (
|
||||
<FilePickerTree
|
||||
key={idx}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onRun={onRun}
|
||||
/>
|
||||
))}
|
||||
</FilePickerItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
|
||||
const terminalOpen = useTerminalStore((s) => s.isOpen);
|
||||
const jobs = useTerminalStore((s) => s.jobs);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
}}
|
||||
>
|
||||
{/* Terminal — сверху, над списком файлов */}
|
||||
{terminalOpen && jobs.length > 0 && (
|
||||
<div style={{ height: 250 }}>
|
||||
<TerminalOutput />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(files.children || []).map((child, idx) => (
|
||||
<FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from "react";
|
||||
import {
|
||||
FiChevronRight,
|
||||
FiChevronDown,
|
||||
FiFile,
|
||||
FiFolder,
|
||||
FiPlay,
|
||||
} from "react-icons/fi";
|
||||
|
||||
interface FilePickerItemProps {
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
path: string;
|
||||
isExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
onToggleSelect?: (path: string) => void;
|
||||
onToggleFolder?: (path: string) => void;
|
||||
onRun?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||
name,
|
||||
type,
|
||||
path,
|
||||
isExpanded,
|
||||
children,
|
||||
level,
|
||||
onToggleSelect,
|
||||
onToggleFolder,
|
||||
onRun,
|
||||
}) => {
|
||||
const isFolder = type === "folder";
|
||||
const extension = name.includes(".")
|
||||
? name.split(".").pop()?.toUpperCase()
|
||||
: "";
|
||||
const paddingLeft = 12 + level * 20;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
paddingRight: "12px",
|
||||
height: "36px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.1s",
|
||||
gap: "8px",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isFolder && onToggleFolder) {
|
||||
onToggleFolder(path);
|
||||
} else if (!isFolder && onToggleSelect) {
|
||||
onToggleSelect(path);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
{/* Folder expand icon */}
|
||||
{isFolder && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<FiChevronDown size={14} />
|
||||
) : (
|
||||
<FiChevronRight size={14} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* File/Folder icon */}
|
||||
<span style={{ display: "flex", flexShrink: 0 }}>
|
||||
{isFolder ? (
|
||||
<FiFolder size={15} color="var(--accent)" />
|
||||
) : (
|
||||
<FiFile size={15} color="var(--text-secondary)" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{/* Extension badge — только у файлов */}
|
||||
{!isFolder && extension && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
padding: "2px 6px",
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderRadius: "3px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{extension}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Run button — только у файлов */}
|
||||
{!isFolder && onRun && (
|
||||
<button
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4px",
|
||||
backgroundColor: "transparent",
|
||||
border: "1px solid transparent",
|
||||
borderRadius: "3px",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRun(path);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#238636";
|
||||
e.currentTarget.style.color = "#ffffff";
|
||||
e.currentTarget.style.borderColor = "#2ea043";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
e.currentTarget.style.color = "var(--text-secondary)";
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
title="Run script"
|
||||
>
|
||||
<FiPlay size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isFolder && isExpanded && children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState } from "react";
|
||||
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
|
||||
import { GoFile } from "react-icons/go";
|
||||
import type { FileNode } from "../types";
|
||||
|
||||
interface FileTreeItemProps {
|
||||
node: FileNode;
|
||||
level: number;
|
||||
onFileSelect: (node: FileNode) => void;
|
||||
selectedFile: string | null;
|
||||
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
|
||||
expandedFolders: Set<string>;
|
||||
onToggleFolder: (path: string) => void;
|
||||
onDelete: (node: FileNode) => void;
|
||||
isRoot?: boolean;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
|
||||
node,
|
||||
level,
|
||||
onFileSelect,
|
||||
selectedFile,
|
||||
onContextMenu,
|
||||
expandedFolders,
|
||||
onToggleFolder,
|
||||
onDelete,
|
||||
isRoot,
|
||||
searchQuery,
|
||||
}) => {
|
||||
const isFolder = node.type === "folder";
|
||||
const isSelected = selectedFile === node.path && !isFolder;
|
||||
const isExpanded = expandedFolders.has(node.path || node.name);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isFolder) {
|
||||
onToggleFolder(node.path || node.name);
|
||||
} else {
|
||||
onFileSelect(node);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node);
|
||||
};
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</span>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => onContextMenu(e, node)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
|
||||
paddingTop: "4px",
|
||||
paddingBottom: "4px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
backgroundColor: isSelected ? "#094771" : "transparent",
|
||||
color: isSelected ? "#fff" : "#cccccc",
|
||||
fontSize: "13px",
|
||||
transition: "background-color 0.1s",
|
||||
userSelect: "none",
|
||||
minHeight: "28px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
width: "16px",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isFolder ? (
|
||||
isExpanded ? (
|
||||
<FiChevronDown />
|
||||
) : (
|
||||
<FiChevronRight />
|
||||
)
|
||||
) : (
|
||||
<GoFile />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
|
||||
</span>
|
||||
{hovered && !isRoot && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
title={`Delete ${node.name}`}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "2px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "3px",
|
||||
flexShrink: 0,
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "#f48771";
|
||||
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#858585";
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<FiTrash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isFolder && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child, idx) => (
|
||||
<FileTreeItem
|
||||
key={idx}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onFileSelect={onFileSelect}
|
||||
selectedFile={selectedFile}
|
||||
onContextMenu={onContextMenu}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
onDelete={onDelete}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import type { Interpreter } from "../types";
|
||||
|
||||
interface InputDialogProps {
|
||||
title: string;
|
||||
initialValue?: string;
|
||||
onConfirm: (value: string, interpreterId?: number) => void;
|
||||
onCancel: () => void;
|
||||
interpreters?: Interpreter[];
|
||||
}
|
||||
|
||||
export const InputDialog: React.FC<InputDialogProps> = ({
|
||||
title,
|
||||
initialValue = "",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
interpreters,
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [interpreterId, setInterpreterId] = useState<number | undefined>(
|
||||
interpreters?.[0]?.id,
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const showInterpreterDropdown = interpreters && interpreters.length > 0;
|
||||
|
||||
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={onCancel}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#2d2d30",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
minWidth: "320px",
|
||||
border: "1px solid #3e3e42",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
color: "#fff",
|
||||
fontSize: "16px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
||||
Enter a name
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
value.trim() &&
|
||||
onConfirm(value.trim(), interpreterId)
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
backgroundColor: "#3c3c3c",
|
||||
border: "1px solid #3e3e42",
|
||||
borderRadius: "6px",
|
||||
color: "#ccc",
|
||||
fontSize: "14px",
|
||||
marginBottom: showInterpreterDropdown ? "12px" : "20px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Interpreter dropdown */}
|
||||
{showInterpreterDropdown && (
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "#858585",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Interpreter
|
||||
</label>
|
||||
<select
|
||||
value={interpreterId}
|
||||
onChange={(e) => setInterpreterId(Number(e.target.value))}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
backgroundColor: "#3c3c3c",
|
||||
border: "1px solid #3e3e42",
|
||||
borderRadius: "6px",
|
||||
color: "#ccc",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{interpreters.map((interp) => (
|
||||
<option key={interp.id} value={interp.id}>
|
||||
{interp.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
|
||||
>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
backgroundColor: "transparent",
|
||||
border: "1px solid #0e639c",
|
||||
borderRadius: "4px",
|
||||
color: "#0e639c",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
value.trim() && onConfirm(value.trim(), interpreterId)
|
||||
}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
backgroundColor: "#0e639c",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { MdClose } from "react-icons/md";
|
||||
import { scriptsApi } from "../api/scripts.api";
|
||||
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||
|
||||
interface RunScriptModalProps {
|
||||
scriptPath: string;
|
||||
scriptId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RunScriptModal: React.FC<RunScriptModalProps> = ({
|
||||
scriptPath,
|
||||
scriptId,
|
||||
onClose,
|
||||
}) => {
|
||||
const [selectedAgentIdx, setSelectedAgentIdx] = useState(0);
|
||||
const [stdinValue, setStdinValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
const agents = useAgentStore((s) => s.agents);
|
||||
const addJob = useTerminalStore((s) => s.addJob);
|
||||
const openTerminal = useTerminalStore((s) => s.openTerminal);
|
||||
|
||||
const selectedAgent = agents[selectedAgentIdx];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!selectedAgent) {
|
||||
setError("No agents available");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Запускаем скрипт
|
||||
const runResult = await scriptsApi.runScript(scriptId, {
|
||||
stdin: stdinValue,
|
||||
token: selectedAgent.token,
|
||||
});
|
||||
|
||||
// 2. Добавляем джоб в терминал
|
||||
addJob({
|
||||
id: runResult.id,
|
||||
scriptPath,
|
||||
command: runResult.command,
|
||||
});
|
||||
|
||||
// 3. Открываем терминал
|
||||
openTerminal();
|
||||
|
||||
// 4. Ждём завершения по id
|
||||
const jobResult = await scriptsApi.waitJob(runResult.id);
|
||||
|
||||
// 5. Обновляем существующий джоб (не создаём новый!)
|
||||
const terminalStore = useTerminalStore.getState();
|
||||
terminalStore.updateJob(runResult.id, {
|
||||
command: jobResult.command,
|
||||
stdin: jobResult.stdin,
|
||||
status: jobResult.status,
|
||||
stdout: jobResult.stdout,
|
||||
stderr: jobResult.stderr,
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error("Failed to run script:", e);
|
||||
setError(e?.response?.data?.detail || "Failed to run script");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--card-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
width: "420px",
|
||||
maxWidth: "90vw",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Run Script
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<MdClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: "20px" }}>
|
||||
{/* Script path */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Script
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "monospace",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{scriptPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent selector */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Agent <span style={{ color: "#f44747" }}>*</span>
|
||||
</label>
|
||||
<select
|
||||
ref={inputRef}
|
||||
value={selectedAgentIdx}
|
||||
onChange={(e) => setSelectedAgentIdx(Number(e.target.value))}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{agents.length === 0 && (
|
||||
<option value="">No agents available</option>
|
||||
)}
|
||||
{agents.map((agent, idx) => (
|
||||
<option key={agent.label} value={idx}>
|
||||
{agent.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stdin (optional) */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "12px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Stdin <span style={{ color: "#858585" }}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={stdinValue}
|
||||
onChange={(e) => setStdinValue(e.target.value)}
|
||||
placeholder="Enter input data..."
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "var(--input-bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "monospace",
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
||||
border: "1px solid #f44747",
|
||||
borderRadius: "4px",
|
||||
color: "#f44747",
|
||||
fontSize: "12px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run button */}
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={loading || !selectedAgent}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
backgroundColor: loading || !selectedAgent ? "#555" : "#0e639c",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
color: "#ffffff",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
cursor: loading || !selectedAgent ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⏳
|
||||
</span>
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>▶ Run</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { FiGitBranch, FiCheckCircle, FiAlertCircle } from "react-icons/fi";
|
||||
import type { FileNode } from "../types";
|
||||
|
||||
interface StatusBarProps {
|
||||
activeFile: FileNode | null;
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({ activeFile }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "22px",
|
||||
backgroundColor: "#007acc",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 12px",
|
||||
fontSize: "12px",
|
||||
color: "#ffffff",
|
||||
userSelect: "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<FiGitBranch size={12} /> main
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||
{activeFile && (
|
||||
<span>
|
||||
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
||||
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
||||
</span>
|
||||
)}
|
||||
<span>Web VS Code</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from "react";
|
||||
import { GoFile } from "react-icons/go";
|
||||
import { MdClose } from "react-icons/md";
|
||||
import type { FileNode } from "../types";
|
||||
|
||||
interface TabBarProps {
|
||||
openFiles: FileNode[];
|
||||
activeFile: FileNode | null;
|
||||
onSelectFile: (file: FileNode) => void;
|
||||
onCloseFile: (file: FileNode) => void;
|
||||
onCloseAll: () => void;
|
||||
onCloseOthers: (file: FileNode) => void;
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<TabBarProps> = ({
|
||||
openFiles,
|
||||
activeFile,
|
||||
onSelectFile,
|
||||
onCloseFile,
|
||||
onCloseAll,
|
||||
onCloseOthers,
|
||||
}) => {
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
file: FileNode;
|
||||
} | null>(null);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, file: FileNode) => {
|
||||
e.preventDefault();
|
||||
setShowContextMenu({ x: e.clientX, y: e.clientY, file });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1e1e1e",
|
||||
borderBottom: "1px solid #3e3e42",
|
||||
overflowX: "auto",
|
||||
minHeight: "40px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
padding: "0 12px",
|
||||
gap: "8px",
|
||||
borderRight: "1px solid #3e3e42",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onCloseAll}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#cccccc",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
transition: "all 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
title="Close All"
|
||||
>
|
||||
<MdClose size={14} />
|
||||
<span style={{ fontSize: "11px" }}>Close All</span>
|
||||
</button>
|
||||
</div>
|
||||
{openFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => onSelectFile(file)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
backgroundColor:
|
||||
activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30",
|
||||
color: activeFile?.path === file.path ? "#fff" : "#cccccc",
|
||||
borderRight: "1px solid #3e3e42",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
gap: "10px",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "all 0.1s",
|
||||
borderTop:
|
||||
activeFile?.path === file.path
|
||||
? "2px solid #0e639c"
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
<GoFile />
|
||||
<span>{file.name}</span>
|
||||
{file.dirty && (
|
||||
<span
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fbbf24",
|
||||
marginLeft: "-4px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseFile(file);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
fontSize: "16px",
|
||||
padding: "0 4px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "#fff";
|
||||
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "#858585";
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<MdClose size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showContextMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: showContextMenu.y,
|
||||
left: showContextMenu.x,
|
||||
backgroundColor: "#252526",
|
||||
border: "1px solid #3e3e42",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||
zIndex: 1000,
|
||||
minWidth: "160px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onCloseOthers(showContextMenu.file);
|
||||
setShowContextMenu(null);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
color: "#cccccc",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
Close Others
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onCloseAll();
|
||||
setShowContextMenu(null);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
color: "#cccccc",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
Close All
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { FiGitBranch, FiCheckCircle } from "react-icons/fi";
|
||||
|
||||
export const TitleBar: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "32px",
|
||||
backgroundColor: "#2d2d30",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 12px",
|
||||
borderBottom: "1px solid #3e3e42",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#ed6a5e",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#f5bd4f",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#61c454",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
|
||||
Web VS Code
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<FiGitBranch size={12} color="#858585" />
|
||||
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
|
||||
<FiCheckCircle size={12} color="#61c454" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export { ContextMenu } from "./ContextMenu";
|
||||
export { InputDialog } from "./InputDialog";
|
||||
export { FileTreeItem } from "./FileTreeItem";
|
||||
export { FileExplorer } from "./FileExplorer";
|
||||
export { TabBar } from "./TabBar";
|
||||
export { CodeEditor } from "./CodeEditor";
|
||||
export { TitleBar } from "./TitleBar";
|
||||
export { StatusBar } from "./StatusBar";
|
||||
export { FilePickerItem } from "./FilePickerItem";
|
||||
export { FilePicker } from "./FilePicker";
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { FileNode } from "../types";
|
||||
|
||||
export const addPaths = (node: FileNode, parentPath: string = ""): FileNode => {
|
||||
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||
const newNode = { ...node, path: currentPath };
|
||||
if (newNode.children) {
|
||||
newNode.children = newNode.children.map((child) =>
|
||||
addPaths(child, currentPath),
|
||||
);
|
||||
}
|
||||
return newNode;
|
||||
};
|
||||
|
||||
export const getAllFolderPaths = (node: FileNode): string[] => {
|
||||
let paths: string[] = [];
|
||||
if (node.type === "folder") {
|
||||
paths.push(node.path || node.name);
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => {
|
||||
paths = [...paths, ...getAllFolderPaths(child)];
|
||||
});
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const findNode = (node: FileNode, path: string): FileNode | null => {
|
||||
if (node.path === path) return node;
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = findNode(child, path);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const deleteNode = (node: FileNode, path: string): FileNode | null => {
|
||||
if (node.path === path) return null;
|
||||
|
||||
if (node.children) {
|
||||
const filtered = node.children.filter((child) => child.path !== path);
|
||||
const mapped = filtered
|
||||
.map((child) => deleteNode(child, path))
|
||||
.filter((child): child is FileNode => child !== null);
|
||||
return { ...node, children: mapped };
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
export const addNode = (
|
||||
node: FileNode,
|
||||
parentPath: string,
|
||||
newNode: FileNode,
|
||||
): FileNode => {
|
||||
if (node.path === parentPath) {
|
||||
const newPath = addPaths(newNode, node.path);
|
||||
return { ...node, children: [...(node.children || []), newPath] };
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map((child) =>
|
||||
addNode(child, parentPath, newNode),
|
||||
),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
export const renameNode = (
|
||||
node: FileNode,
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
): FileNode | null => {
|
||||
if (node.path === oldPath) {
|
||||
const pathParts = node.path?.split("/") || [];
|
||||
pathParts[pathParts.length - 1] = newName;
|
||||
const newPath = pathParts.join("/");
|
||||
const renamedNode = { ...node, name: newName, path: newPath };
|
||||
|
||||
if (renamedNode.children) {
|
||||
renamedNode.children = renamedNode.children.map((child) => {
|
||||
const oldChildPath = child.path || "";
|
||||
const newChildPath = oldChildPath.replace(oldPath, newPath);
|
||||
return (
|
||||
renameNode(
|
||||
child,
|
||||
oldChildPath,
|
||||
newChildPath.split("/").pop() || "",
|
||||
) || child
|
||||
);
|
||||
});
|
||||
}
|
||||
return renamedNode;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map(
|
||||
(child) => renameNode(child, oldPath, newName) || child,
|
||||
),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
export const filterTree = (node: FileNode, query: string): FileNode | null => {
|
||||
if (!query) return node;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
if (node.type === "file") {
|
||||
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
const filteredChildren = node.children
|
||||
.map((child) => filterTree(child, query))
|
||||
.filter((child): child is FileNode => child !== null);
|
||||
|
||||
if (filteredChildren.length > 0) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
}
|
||||
|
||||
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const collectPathsToExpand = (
|
||||
node: FileNode,
|
||||
query: string,
|
||||
): Set<string> => {
|
||||
const paths = new Set<string>();
|
||||
if (!query) return paths;
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const search = (n: FileNode, currentPath: string) => {
|
||||
if (n.name.toLowerCase().includes(lowerQuery)) {
|
||||
const pathParts = currentPath.split("/");
|
||||
for (let i = 1; i < pathParts.length; i++) {
|
||||
paths.add(pathParts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
if (n.children) {
|
||||
n.children.forEach((child) => {
|
||||
const childPath = child.path || `${currentPath}/${child.name}`;
|
||||
search(child, childPath);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
search(node, node.path || node.name);
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const getLanguage = (path: string) => {
|
||||
const ext = path.split(".").pop();
|
||||
const map: Record<string, string> = {
|
||||
py: "python",
|
||||
js: "javascript",
|
||||
ts: "typescript",
|
||||
jsx: "javascript",
|
||||
tsx: "typescript",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
css: "css",
|
||||
html: "html",
|
||||
};
|
||||
return map[ext || ""] || "plaintext";
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { IDE } from "./IDE";
|
||||
export { FilePicker } from "./components/FilePicker";
|
||||
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
||||
export { useFilePickerStore } from "./store/useFilePickerStore";
|
||||
export type { FileNode } from "./types";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface FilePickerState {
|
||||
selectedPaths: Set<string>;
|
||||
expandedFolders: Set<string>;
|
||||
|
||||
toggleSelection: (path: string) => void;
|
||||
selectAll: (paths: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
toggleFolder: (path: string) => void;
|
||||
getSelectedPaths: () => string[];
|
||||
}
|
||||
|
||||
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
||||
selectedPaths: new Set(),
|
||||
expandedFolders: new Set(),
|
||||
|
||||
toggleSelection: (path: string) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.selectedPaths);
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path);
|
||||
} else {
|
||||
newSet.add(path);
|
||||
}
|
||||
return { selectedPaths: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
selectAll: (paths: string[]) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.selectedPaths);
|
||||
paths.forEach((p) => newSet.add(p));
|
||||
return { selectedPaths: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedPaths: new Set() });
|
||||
},
|
||||
|
||||
toggleFolder: (path: string) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.expandedFolders);
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path);
|
||||
} else {
|
||||
newSet.add(path);
|
||||
}
|
||||
return { expandedFolders: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
getSelectedPaths: () => {
|
||||
return Array.from(get().selectedPaths);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,641 @@
|
||||
import { create } from "zustand";
|
||||
import type { FileNode, Interpreter, DialogState } from "../types";
|
||||
import {
|
||||
addPaths,
|
||||
getAllFolderPaths,
|
||||
findNode,
|
||||
deleteNode,
|
||||
addNode,
|
||||
renameNode,
|
||||
} from "../helpers/fileTree";
|
||||
import { scriptsApi } from "../api/scripts.api";
|
||||
|
||||
export const initialFiles: FileNode = {
|
||||
name: "my-project",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "src",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
name: "main.py",
|
||||
type: "file",
|
||||
content:
|
||||
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()',
|
||||
},
|
||||
{
|
||||
name: "utils.py",
|
||||
type: "file",
|
||||
content: "def helper():\n return 42",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "file",
|
||||
content: "# My Project\n\nWelcome!",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface IDEFileNode extends FileNode {
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
interface IDEState {
|
||||
// Файловая система
|
||||
files: FileNode | null;
|
||||
openFiles: IDEFileNode[];
|
||||
activeFile: IDEFileNode | null;
|
||||
expandedFolders: Set<string>;
|
||||
searchQuery: string;
|
||||
showSearch: boolean;
|
||||
isInitialized: boolean;
|
||||
interpreters: Interpreter[];
|
||||
|
||||
// Диалоги и контекстные меню
|
||||
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
||||
dialog: DialogState | null;
|
||||
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
||||
|
||||
// Действия с файлами
|
||||
selectFile: (node: FileNode) => void;
|
||||
updateFileContent: (content: string) => void;
|
||||
saveActiveFile: () => Promise<void>;
|
||||
closeFile: (file: FileNode) => void;
|
||||
closeAllFiles: () => void;
|
||||
closeOtherFiles: (file: FileNode) => void;
|
||||
|
||||
// Действия с деревом
|
||||
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => void;
|
||||
toggleFolder: (path: string) => void;
|
||||
expandAllFolders: () => void;
|
||||
collapseAllFolders: () => void;
|
||||
autoExpandPaths: (paths: Set<string>) => void;
|
||||
deleteRoot: () => void;
|
||||
createNewProject: () => void;
|
||||
|
||||
// Интерпретаторы
|
||||
fetchInterpreters: () => Promise<void>;
|
||||
|
||||
// API методы
|
||||
fetchTree: () => Promise<void>;
|
||||
createScript: (payload: {
|
||||
content: string;
|
||||
interpreter_id: number;
|
||||
path: string;
|
||||
}) => Promise<void>;
|
||||
createFolder: (path: string) => Promise<void>;
|
||||
updateScript: (
|
||||
id: number,
|
||||
payload: { content: string; interpreter_id: number; path: string },
|
||||
) => Promise<void>;
|
||||
deleteScript: (id: number) => Promise<void>;
|
||||
deleteFolder: (payload: { path: string }) => Promise<void>;
|
||||
saveActiveFile: () => Promise<void>;
|
||||
|
||||
// Поиск
|
||||
setSearchQuery: (query: string) => void;
|
||||
toggleSearch: () => void;
|
||||
|
||||
// Контекстные меню и диалоги
|
||||
setContextMenu: (
|
||||
menu: { x: number; y: number; node: FileNode | null } | null,
|
||||
) => void;
|
||||
setDialog: (
|
||||
dialog: {
|
||||
type: "newFile" | "newFolder" | "rename";
|
||||
node: FileNode | null;
|
||||
} | null,
|
||||
) => void;
|
||||
setTabContextMenu: (
|
||||
menu: { x: number; y: number; file: FileNode } | null,
|
||||
) => void;
|
||||
|
||||
// Инициализация
|
||||
initialize: (initialFiles: FileNode) => void;
|
||||
|
||||
// Диалог подтверждения
|
||||
handleDialogConfirm: (value: string, interpreterId?: number) => Promise<void>;
|
||||
handleDeleteNode: (node: FileNode) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useIDEStore = create<IDEState>((set, get) => ({
|
||||
// Начальное состояние
|
||||
files: null,
|
||||
openFiles: [],
|
||||
activeFile: null,
|
||||
expandedFolders: new Set(),
|
||||
searchQuery: "",
|
||||
showSearch: false,
|
||||
isInitialized: false,
|
||||
|
||||
contextMenu: null,
|
||||
dialog: null,
|
||||
tabContextMenu: null,
|
||||
interpreters: [],
|
||||
|
||||
// Инициализация
|
||||
initialize: (initialFiles: FileNode) => {
|
||||
const filesWithPaths = addPaths(initialFiles);
|
||||
set({
|
||||
files: filesWithPaths,
|
||||
expandedFolders: new Set([filesWithPaths.path || filesWithPaths.name]),
|
||||
isInitialized: true,
|
||||
});
|
||||
},
|
||||
|
||||
// Выбор файла
|
||||
selectFile: (node: FileNode) => {
|
||||
if (node.type === "file") {
|
||||
const { openFiles, files } = get();
|
||||
// Берём актуальную версию из дерева файлов
|
||||
const latestFile = files ? findNode(files, node.path || "") : null;
|
||||
const fileToOpen =
|
||||
latestFile && latestFile.type === "file" ? latestFile : node;
|
||||
|
||||
if (!openFiles.find((f) => f.path === fileToOpen.path)) {
|
||||
set((state) => ({ openFiles: [...state.openFiles, fileToOpen] }));
|
||||
}
|
||||
set({ activeFile: fileToOpen });
|
||||
}
|
||||
},
|
||||
|
||||
// Обновление содержимого файла
|
||||
updateFileContent: (content: string) => {
|
||||
const { activeFile, files } = get();
|
||||
if (activeFile && files) {
|
||||
const updatedFile = { ...activeFile, content, dirty: true };
|
||||
set({ activeFile: updatedFile });
|
||||
set((state) => ({
|
||||
openFiles: state.openFiles.map((f) =>
|
||||
f.path === activeFile.path ? updatedFile : f,
|
||||
),
|
||||
}));
|
||||
|
||||
// Обновляем также в дереве файлов
|
||||
const updateFileInTree = (node: FileNode): FileNode => {
|
||||
if (node.path === activeFile.path) {
|
||||
return updatedFile;
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map((child) => updateFileInTree(child)),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
};
|
||||
set({ files: updateFileInTree(files) });
|
||||
}
|
||||
},
|
||||
|
||||
// Закрытие файла
|
||||
closeFile: (file: FileNode) => {
|
||||
const { openFiles, activeFile } = get();
|
||||
const newOpenFiles = openFiles.filter((f) => f.path !== file.path);
|
||||
set({ openFiles: newOpenFiles });
|
||||
|
||||
if (activeFile?.path === file.path) {
|
||||
set({ activeFile: newOpenFiles[newOpenFiles.length - 1] || null });
|
||||
}
|
||||
},
|
||||
|
||||
// Закрыть все файлы
|
||||
closeAllFiles: () => {
|
||||
set({ openFiles: [], activeFile: null });
|
||||
},
|
||||
|
||||
// Закрыть другие файлы
|
||||
closeOtherFiles: (file: FileNode) => {
|
||||
set({ openFiles: [file], activeFile: file });
|
||||
},
|
||||
|
||||
// Обновить файловую систему
|
||||
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => {
|
||||
const { openFiles, activeFile, selectFile } = get();
|
||||
|
||||
set({ files: newFiles });
|
||||
|
||||
if (!newFiles) {
|
||||
set({ openFiles: [], activeFile: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedOpenFiles = openFiles
|
||||
.map((f) => {
|
||||
const found = findNode(newFiles, f.path || "");
|
||||
return found && found.type === "file" ? found : null;
|
||||
})
|
||||
.filter((f): f is FileNode => f !== null);
|
||||
|
||||
set({ openFiles: updatedOpenFiles });
|
||||
|
||||
if (newFile) {
|
||||
selectFile(newFile);
|
||||
} else if (activeFile) {
|
||||
const stillExists = findNode(newFiles, activeFile.path || "");
|
||||
if (!stillExists) {
|
||||
set({
|
||||
activeFile: updatedOpenFiles[updatedOpenFiles.length - 1] || null,
|
||||
});
|
||||
} else if (stillExists.type === "file") {
|
||||
set({ activeFile: stillExists });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Переключить папку
|
||||
toggleFolder: (path: string) => {
|
||||
set((state) => {
|
||||
const newSet = new Set(state.expandedFolders);
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path);
|
||||
} else {
|
||||
newSet.add(path);
|
||||
}
|
||||
return { expandedFolders: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
// Раскрыть все папки
|
||||
expandAllFolders: () => {
|
||||
const { files } = get();
|
||||
if (files) {
|
||||
set({ expandedFolders: new Set(getAllFolderPaths(files)) });
|
||||
}
|
||||
},
|
||||
|
||||
// Свернуть все папки
|
||||
collapseAllFolders: () => {
|
||||
set({ expandedFolders: new Set() });
|
||||
},
|
||||
|
||||
// Автоматически раскрыть пути
|
||||
autoExpandPaths: (paths: Set<string>) => {
|
||||
set((state) => ({
|
||||
expandedFolders: new Set([...state.expandedFolders, ...paths]),
|
||||
}));
|
||||
},
|
||||
|
||||
// Удалить корень
|
||||
deleteRoot: () => {
|
||||
set({
|
||||
files: null,
|
||||
openFiles: [],
|
||||
activeFile: null,
|
||||
expandedFolders: new Set(),
|
||||
});
|
||||
},
|
||||
|
||||
// Создать новый проект
|
||||
createNewProject: () => {
|
||||
const newProject = addPaths(initialFiles);
|
||||
set({
|
||||
files: newProject,
|
||||
expandedFolders: new Set([newProject.path || newProject.name]),
|
||||
searchQuery: "",
|
||||
});
|
||||
},
|
||||
|
||||
// Интерпретаторы
|
||||
fetchInterpreters: async () => {
|
||||
try {
|
||||
const interpreters = await scriptsApi.getInterpreters();
|
||||
set({ interpreters });
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch interpreters:", e);
|
||||
}
|
||||
},
|
||||
|
||||
// API: загрузка дерева с сервера
|
||||
fetchTree: async () => {
|
||||
try {
|
||||
const data = await scriptsApi.getTree();
|
||||
const { expandedFolders } = get();
|
||||
|
||||
const convertItem = (item: any): FileNode => {
|
||||
const node: FileNode = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type === "folder" ? "folder" : "file",
|
||||
content: item.content || "",
|
||||
path: item.name,
|
||||
interpreter_id: item.interpreter_id,
|
||||
};
|
||||
|
||||
if (item.type === "folder") {
|
||||
node.children = [];
|
||||
if (item.children && Array.isArray(item.children)) {
|
||||
node.children = item.children.map((child: any) => {
|
||||
const childNode = convertItem(child);
|
||||
childNode.path = `${item.name}/${child.name}`;
|
||||
return childNode;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const roots = data.map((item) => convertItem(item));
|
||||
|
||||
set({
|
||||
files: {
|
||||
name: "scripts",
|
||||
type: "folder",
|
||||
children: roots,
|
||||
},
|
||||
expandedFolders,
|
||||
isInitialized: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch tree:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: создание скрипта
|
||||
createScript: async (payload) => {
|
||||
try {
|
||||
await scriptsApi.createScript(payload);
|
||||
await get().fetchTree();
|
||||
} catch (e) {
|
||||
console.error("Failed to create script:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: создание папки
|
||||
createFolder: async (path: string) => {
|
||||
try {
|
||||
await scriptsApi.createFolder(path);
|
||||
await get().fetchTree();
|
||||
} catch (e) {
|
||||
console.error("Failed to create folder:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: удаление папки
|
||||
deleteFolder: async ({ path }: { path: string }) => {
|
||||
try {
|
||||
const { openFiles } = get();
|
||||
|
||||
// Закрываем все файлы, которые находятся в удаляемой папке
|
||||
const folderPathPrefix = path.endsWith("/") ? path : `${path}/`;
|
||||
const filesToClose = openFiles.filter(
|
||||
(f) => f.path === path || f.path?.startsWith(folderPathPrefix),
|
||||
);
|
||||
filesToClose.forEach((f) => get().closeFile(f));
|
||||
|
||||
await scriptsApi.deleteFolder(path);
|
||||
await get().fetchTree();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete folder:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: обновление скрипта
|
||||
updateScript: async (id, payload) => {
|
||||
try {
|
||||
await scriptsApi.updateScript(id, payload);
|
||||
} catch (e) {
|
||||
console.error("Failed to update script:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: удаление скрипта
|
||||
deleteScript: async (id) => {
|
||||
try {
|
||||
await scriptsApi.deleteScript(id);
|
||||
await get().fetchTree();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete script:", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// API: сохранение активного файла
|
||||
saveActiveFile: async () => {
|
||||
const { activeFile } = get();
|
||||
if (!activeFile || !activeFile.id) return;
|
||||
|
||||
try {
|
||||
await scriptsApi.updateScript(activeFile.id, {
|
||||
content: activeFile.content || "",
|
||||
interpreter_id: activeFile.interpreter_id || 0,
|
||||
path: activeFile.path || "",
|
||||
});
|
||||
set((state) => ({
|
||||
activeFile: state.activeFile
|
||||
? { ...state.activeFile, dirty: false }
|
||||
: null,
|
||||
openFiles: state.openFiles.map((f) =>
|
||||
f.path === state.activeFile?.path ? { ...f, dirty: false } : f,
|
||||
),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("Failed to save file:", e);
|
||||
}
|
||||
},
|
||||
|
||||
// Поиск
|
||||
setSearchQuery: (query: string) => {
|
||||
set({ searchQuery: query });
|
||||
},
|
||||
|
||||
toggleSearch: () => {
|
||||
set((state) => ({ showSearch: !state.showSearch }));
|
||||
},
|
||||
|
||||
// Контекстные меню и диалоги
|
||||
setContextMenu: (menu) => set({ contextMenu: menu }),
|
||||
setDialog: (dialog) => set({ dialog: dialog }),
|
||||
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
|
||||
|
||||
// Подтверждение диалога
|
||||
handleDialogConfirm: async (value: string, interpreterId?: number) => {
|
||||
const { dialog, files, toggleFolder, autoExpandPaths } = get();
|
||||
if (!dialog) return;
|
||||
|
||||
if (dialog.type === "rename" && dialog.node) {
|
||||
const parentPath =
|
||||
dialog.node.path?.split("/").slice(0, -1).join("/") || "";
|
||||
const parentNode = parentPath ? findNode(files!, parentPath) : files;
|
||||
if (
|
||||
parentNode?.children?.some(
|
||||
(c) =>
|
||||
c.name.toLowerCase() === value.toLowerCase() &&
|
||||
c.path !== dialog.node?.path,
|
||||
)
|
||||
) {
|
||||
alert(`"${value}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPath = dialog.node.path || dialog.node.name;
|
||||
const newPath = parentPath ? `${parentPath}/${value}` : value;
|
||||
|
||||
// Сохраняем раскрытые папки
|
||||
const savedExpandedFolders = new Set(get().expandedFolders);
|
||||
|
||||
try {
|
||||
await scriptsApi.rename({ old_path: oldPath, new_path: newPath });
|
||||
await get().fetchTree();
|
||||
|
||||
// Восстанавливаем раскрытые папки
|
||||
set({ expandedFolders: savedExpandedFolders });
|
||||
|
||||
// Раскрываем родительскую цепочку
|
||||
const allParentPaths: string[] = [];
|
||||
let current = parentPath;
|
||||
while (current) {
|
||||
allParentPaths.push(current);
|
||||
const parts = current.split("/");
|
||||
parts.pop();
|
||||
current = parts.join("/");
|
||||
}
|
||||
autoExpandPaths(new Set(allParentPaths));
|
||||
|
||||
// Если переименованный файл был открыт — обновим его в openFiles
|
||||
const { openFiles, activeFile } = get();
|
||||
const updatedOpenFiles = openFiles.map((f) =>
|
||||
f.path === oldPath ? { ...f, name: value, path: newPath } : f,
|
||||
);
|
||||
set({ openFiles: updatedOpenFiles });
|
||||
|
||||
if (activeFile?.path === oldPath) {
|
||||
set({ activeFile: { ...activeFile, name: value, path: newPath } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to rename:", e);
|
||||
}
|
||||
|
||||
set({ dialog: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем родительский путь
|
||||
let parentPath: string;
|
||||
if (!dialog.node) {
|
||||
parentPath = "";
|
||||
} else if (dialog.node.type === "folder") {
|
||||
parentPath = dialog.node.path || dialog.node.name;
|
||||
} else {
|
||||
const pathParts = (dialog.node.path || dialog.node.name).split("/");
|
||||
pathParts.pop();
|
||||
parentPath = pathParts.join("/");
|
||||
}
|
||||
|
||||
// Проверяем наличие расширения
|
||||
const hasExtension =
|
||||
value.includes(".") && value.split(".").pop() !== value;
|
||||
let finalName = value;
|
||||
let isFile = false;
|
||||
|
||||
// Если диалог создания файла
|
||||
if (dialog.type === "newFile") {
|
||||
isFile = true;
|
||||
// Если нет расширения — добавляем .txt
|
||||
if (!hasExtension) {
|
||||
finalName = `${value}.txt`;
|
||||
}
|
||||
} else if (dialog.type === "newFolder") {
|
||||
// Если диалог создания папки — но имя с расширением, считаем файлом
|
||||
if (hasExtension) {
|
||||
isFile = true;
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = parentPath ? `${parentPath}/${finalName}` : finalName;
|
||||
|
||||
// Сохраняем раскрытые папки ДО перезагрузки дерева
|
||||
const savedExpandedFolders = new Set(get().expandedFolders);
|
||||
|
||||
try {
|
||||
// Создание папки
|
||||
if (dialog.type === "newFolder" && !isFile) {
|
||||
await scriptsApi.createFolder(fullPath);
|
||||
await get().fetchTree();
|
||||
|
||||
// Восстанавливаем раскрытые папки
|
||||
set({ expandedFolders: savedExpandedFolders });
|
||||
|
||||
// Собираем все пути от корня до родительской папки
|
||||
const allParentPaths: string[] = [];
|
||||
let current = parentPath;
|
||||
while (current) {
|
||||
allParentPaths.push(current);
|
||||
const parts = current.split("/");
|
||||
parts.pop();
|
||||
current = parts.join("/");
|
||||
}
|
||||
|
||||
// Раскрываем родительскую цепочку
|
||||
autoExpandPaths(new Set(allParentPaths));
|
||||
} else {
|
||||
// Создание файла
|
||||
const result = await scriptsApi.createScript({
|
||||
content: "",
|
||||
interpreter_id: interpreterId || 0,
|
||||
path: fullPath,
|
||||
});
|
||||
|
||||
await get().fetchTree();
|
||||
|
||||
// Восстанавливаем раскрытые папки
|
||||
set({ expandedFolders: savedExpandedFolders });
|
||||
|
||||
// Собираем все пути от корня до родительской папки
|
||||
const allParentPaths: string[] = [];
|
||||
let current = parentPath;
|
||||
while (current) {
|
||||
allParentPaths.push(current);
|
||||
const parts = current.split("/");
|
||||
parts.pop();
|
||||
current = parts.join("/");
|
||||
}
|
||||
|
||||
// Раскрываем родительскую цепочку
|
||||
autoExpandPaths(new Set(allParentPaths));
|
||||
|
||||
const createdNode: FileNode = {
|
||||
id: result.id,
|
||||
name: finalName,
|
||||
type: "file",
|
||||
content: result.content,
|
||||
path: result.path,
|
||||
interpreter_id: result.interpreter_id,
|
||||
};
|
||||
get().selectFile(createdNode);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to create:", e);
|
||||
}
|
||||
|
||||
set({ dialog: null });
|
||||
},
|
||||
|
||||
// Удаление узла
|
||||
handleDeleteNode: async (node: FileNode) => {
|
||||
const { files } = get();
|
||||
const isRootNode = node.path === files?.path;
|
||||
if (isRootNode) {
|
||||
get().deleteRoot();
|
||||
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
||||
try {
|
||||
if (node.type === "folder") {
|
||||
await get().deleteFolder({ path: node.path || node.name });
|
||||
} else if (node.id) {
|
||||
await get().deleteScript(node.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete:", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
content?: string;
|
||||
children?: FileNode[];
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface Interpreter {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
argv: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
node: FileNode | null;
|
||||
}
|
||||
|
||||
export interface DialogState {
|
||||
type: "newFile" | "newFolder" | "rename";
|
||||
node: FileNode | null;
|
||||
interpreterId?: number;
|
||||
}
|
||||
|
||||
export interface TabContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
file: FileNode;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from "react";
|
||||
import { useTerminalStore } from "../store/useTerminalStore";
|
||||
import { MdClose, MdClearAll } from "react-icons/md";
|
||||
import { FiTerminal } from "react-icons/fi";
|
||||
|
||||
export const TerminalOutput: React.FC = () => {
|
||||
const {
|
||||
jobs,
|
||||
isOpen,
|
||||
activeJobId,
|
||||
closeTerminal,
|
||||
setActiveJob,
|
||||
clearJobs,
|
||||
removeJob,
|
||||
} = useTerminalStore();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId) || jobs[jobs.length - 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#1e1e1e",
|
||||
borderTop: "1px solid #3e3e42",
|
||||
}}
|
||||
>
|
||||
{/* Terminal header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 12px",
|
||||
height: "35px",
|
||||
borderBottom: "1px solid #3e3e42",
|
||||
backgroundColor: "#252526",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<FiTerminal size={14} color="#bbbbbb" />
|
||||
<span
|
||||
style={{
|
||||
color: "#bbbbbb",
|
||||
fontWeight: 500,
|
||||
fontSize: "11px",
|
||||
letterSpacing: "0.8px",
|
||||
}}
|
||||
>
|
||||
TERMINAL
|
||||
</span>
|
||||
{jobs.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
color: "#858585",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "#3c3c3c",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
{jobs.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
{jobs.length > 0 && (
|
||||
<button
|
||||
onClick={clearJobs}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
title="Clear all"
|
||||
>
|
||||
<MdClearAll size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={closeTerminal}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#858585",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
<MdClose size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job tabs */}
|
||||
{jobs.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
backgroundColor: "#2d2d2d",
|
||||
borderBottom: "1px solid #3e3e42",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => setActiveJob(job.id)}
|
||||
style={{
|
||||
padding: "6px 16px",
|
||||
backgroundColor:
|
||||
job.id === activeJobId ? "#1e1e1e" : "transparent",
|
||||
border: "none",
|
||||
borderBottom:
|
||||
job.id === activeJobId
|
||||
? "2px solid #0e639c"
|
||||
: "2px solid transparent",
|
||||
color: job.isRunning ? "#cccccc" : "#858585",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: job.isRunning ? "#4ec9b0" : "#858585",
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
{job.scriptPath.split("/").pop()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal output */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "12px",
|
||||
fontFamily: "'Consolas', 'Courier New', monospace",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
{activeJob ? (
|
||||
<>
|
||||
{/* Command header */}
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<span style={{ color: "#6a9955" }}>$ </span>
|
||||
<span style={{ color: "#cccccc" }}>
|
||||
{activeJob.command.join(" ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stdin if provided */}
|
||||
{activeJob.stdin && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "8px",
|
||||
padding: "8px",
|
||||
backgroundColor: "#2d2d2d",
|
||||
borderRadius: "4px",
|
||||
borderLeft: "3px solid #0e639c",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#858585" }}>stdin: </span>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "#cccccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{activeJob.stdin}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stdout */}
|
||||
{activeJob.stdout && (
|
||||
<pre
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
color: "#cccccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{activeJob.stdout}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Stderr */}
|
||||
{activeJob.stderr && (
|
||||
<pre
|
||||
style={{
|
||||
margin: "0 0 8px 0",
|
||||
color: "#f44747",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{activeJob.stderr}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{activeJob.isRunning ? (
|
||||
<div style={{ color: "#4ec9b0" }}>⏳ Running...</div>
|
||||
) : activeJob.status !== null ? (
|
||||
<div
|
||||
style={{
|
||||
color: activeJob.status === 0 ? "#4ec9b0" : "#f44747",
|
||||
}}
|
||||
>
|
||||
{activeJob.status === 0
|
||||
? "✓ Process exited with code 0"
|
||||
: `✗ Process exited with code ${activeJob.status}`}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: "#858585",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<FiTerminal size={32} />
|
||||
<span>No active jobs</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user