62 Commits

Author SHA1 Message Date
d3m0k1d 0a2d41d04e merge frontend: keep backend README
ci-agent / build (push) Failing after 5m24s
ci-agent / build (pull_request) Failing after 5m10s
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-05 11:02:31 +03:00
d3m0k1d a70791898c fix readme
ci-agent / build (push) Failing after 5m24s
2026-04-05 10:57:14 +03:00
nikita 26323dfd15 super fix
ci-front / build (push) Successful in 2m28s
2026-04-05 10:41:12 +03:00
nikita 7d2f3d0f3a fix 2
ci-front / build (push) Successful in 2m21s
2026-04-05 10:34:33 +03:00
nikita 255fe2eaf3 fix
ci-front / build (push) Successful in 3m18s
2026-04-05 10:14:53 +03:00
d3m0k1d 6d6dd91241 docs
ci-agent / build (push) Failing after 14m57s
2026-04-05 09:32:12 +03:00
nikita 915aa7018a feat: graph 2
ci-front / build (push) Successful in 3m39s
2026-04-05 09:19:39 +03:00
d3m0k1d eb8aef11a4 fix unknow agent
ci-agent / build (push) Failing after 7m40s
2026-04-05 08:46:29 +03:00
d3m0k1d 4a00c95d25 feat: add config for graph test
ci-agent / build (push) Has been cancelled
2026-04-05 08:40:34 +03:00
d3m0k1d e9fdaf8711 upd graph
ci-agent / build (push) Has started running
2026-04-05 08:34:04 +03:00
d3m0k1d 413e31c711 feat: update test config
ci-agent / build (push) Failing after 5m6s
2026-04-05 08:27:35 +03:00
nikita c175461634 fix: adaptive #4
ci-front / build (push) Successful in 2m29s
2026-04-05 08:27:14 +03:00
d3m0k1d f26fa3da69 feat: add logif for checl alive func
ci-agent / build (push) Has been cancelled
2026-04-05 08:25:08 +03:00
nikita 5b90447984 fix: adaptive #3 2026-04-05 08:23:50 +03:00
zero@thinky 247505a310 feat(backend): add root cause calculation
ci-agent / build (push) Has started running
2026-04-05 08:22:41 +03:00
nikita 9f6defd25c fix: adaptive #2
ci-front / build (push) Successful in 2m28s
2026-04-05 08:04:42 +03:00
nikita 5f6c4303db fix: adaptive #1
ci-front / build (push) Successful in 2m39s
2026-04-05 07:41:34 +03:00
nikita 17d4770de6 feat: dashboard
ci-front / build (push) Successful in 2m18s
2026-04-05 07:17:33 +03:00
nikita 337e5891f3 feat: update tamplates
ci-front / build (push) Successful in 2m18s
2026-04-05 07:07:14 +03:00
nikita 2bc3da21fd feat: launch scripts
ci-front / build (push) Successful in 2m19s
2026-04-05 06:54:33 +03:00
nikita d6512d6c97 feat: update button run scripts
ci-front / build (push) Successful in 2m35s
2026-04-05 04:57:16 +03:00
nikita f14490c076 feat: rename
ci-front / build (push) Successful in 2m24s
2026-04-05 03:59:08 +03:00
nikita 178c3b53f7 feat: remove folders & create folder 2026-04-05 03:28:31 +03:00
nikita 5073cfd357 feat: create files
ci-front / build (push) Successful in 2m31s
2026-04-05 02:09:23 +03:00
nikita f71a3b1a03 feat: save files
ci-front / build (push) Successful in 2m15s
2026-04-05 01:37:10 +03:00
nikita e024f91111 fix: render files
ci-front / build (push) Successful in 2m5s
2026-04-05 01:07:03 +03:00
nikita 8f5558fdb7 Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 1m59s
2026-04-05 00:56:55 +03:00
nikita 07066ec8c0 feat: request for tree 2026-04-05 00:56:48 +03:00
NikitaTorbenko 31eecf4ba5 Merge branch 'frontend' of https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN into frontend
ci-front / build (push) Has been cancelled
2026-04-05 00:55:21 +03:00
NikitaTorbenko cf6065b55a feat: complete logs 2026-04-05 00:55:05 +03:00
NikitaTorbenko 43ea41f633 fix: logs filter 2026-04-05 00:13:09 +03:00
nikita 6b82c99d50 fix: sidebar & admin.api
ci-front / build (push) Successful in 2m1s
2026-04-04 22:58:09 +03:00
nikita c73035019f fix: hiden button 'Добавить связь'
ci-front / build (push) Successful in 2m14s
2026-04-04 21:44:39 +03:00
nikita e3fae7a02c Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 1m54s
2026-04-04 21:42:45 +03:00
nikita d46d0f8253 feat: adminka2 2026-04-04 21:41:48 +03:00
NikitaTorbenko bcca8fa298 fix: 401
ci-front / build (push) Successful in 1m57s
2026-04-04 21:38:25 +03:00
NikitaTorbenko 400ceab47c fix: for crush
ci-front / build (push) Has been cancelled
2026-04-04 20:05:25 +03:00
NikitaTorbenko c6a9907822 feat
ci-front / build (push) Successful in 2m26s
2026-04-04 19:49:37 +03:00
NikitaTorbenko 69ff617c30 fix: auth store
ci-front / build (push) Successful in 2m27s
2026-04-04 18:20:52 +03:00
nikita 3430070df8 feat: adminka
ci-front / build (push) Successful in 2m11s
2026-04-04 18:13:54 +03:00
nikita 78f35f6811 feat: dashboard-page
ci-front / build (push) Successful in 2m7s
2026-04-04 16:53:12 +03:00
nikita 55cb214458 feat: themes
ci-front / build (push) Successful in 2m17s
2026-04-04 13:38:32 +03:00
nikita 8175d7b3a5 fix: save code in ide
ci-front / build (push) Successful in 2m11s
2026-04-04 12:57:03 +03:00
nikita 822f953698 fix: forceGraph
ci-front / build (push) Successful in 2m18s
2026-04-04 12:51:35 +03:00
nikita e7f1ea2386 fix: graphs
ci-front / build (push) Successful in 2m5s
2026-04-04 12:38:21 +03:00
nikita aac3fa3758 feat: graph-page
ci-front / build (push) Successful in 1m58s
2026-04-04 12:14:17 +03:00
nikita 26ca7c0d51 redezign: list agents & services; feat: button remove agents
ci-front / build (push) Successful in 2m5s
2026-04-04 11:08:45 +03:00
NikitaTorbenko dd921e5892 fix: logs filter adaptive
ci-front / build (push) Successful in 2m30s
2026-04-04 07:11:41 +03:00
NikitaTorbenko eedc9c9b62 fix: menu adaptive
ci-front / build (push) Successful in 2m18s
2026-04-04 07:06:27 +03:00
nikita 4f69e002c6 Merge branch 'frontend' of gitea.d3m0k1d.ru:d3m0k1d/HellreigN into HEAD
ci-front / build (push) Successful in 2m27s
2026-04-04 06:19:17 +03:00
nikita 5209e8b2e9 fix: conflicts 2026-04-04 06:17:09 +03:00
NikitaTorbenko 95a6902dae feat: create logs
ci-front / build (push) Successful in 2m24s
2026-04-04 06:13:12 +03:00
nikita adbb0ee368 feat: page tempaltes 2026-04-04 06:05:51 +03:00
NikitaTorbenko 96f82b4162 feat: create register
ci-front / build (push) Successful in 2m1s
2026-04-04 05:57:34 +03:00
NikitaTorbenko ed439656f8 feat: add registration token
ci-front / build (push) Successful in 2m22s
2026-04-04 05:52:43 +03:00
NikitaTorbenko d62205b329 fix: add agent
ci-front / build (push) Successful in 2m28s
2026-04-04 05:46:01 +03:00
NikitaTorbenko 11cef95929 feat: add admin + deploy
ci-front / build (push) Successful in 2m28s
2026-04-04 05:39:00 +03:00
nikita 43e16b1360 fix: autocloseder for input search & button back
ci-front / build (push) Successful in 2m23s
2026-04-04 05:13:27 +03:00
nikita f537f1eab9 feat: IDE
ci-front / build (push) Successful in 2m19s
2026-04-04 04:59:42 +03:00
nikita 9d1096a9b4 fix: 2 2026-04-04 03:37:27 +03:00
NikitaTorbenko 57b43da2e3 feat: add layout
ci-front / build (push) Successful in 2m9s
2026-04-04 03:07:45 +03:00
NikitaTorbenko 691e1fced5 feat: add swagger docs
ci-front / build (push) Successful in 2m26s
2026-04-04 02:44:36 +03:00
100 changed files with 22294 additions and 729 deletions
+32
View File
@@ -0,0 +1,32 @@
name: release-agent
on:
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Go setup
uses: actions/setup-go@v6
with:
go-version: "1.26.1"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
workdir: agent
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+358 -1
View File
@@ -1 +1,358 @@
# HellreigN
# HellreigN
Агент внутренней диагностики инфраструктуры. Централизованный сбор логов, мониторинг нагрузки, управление скриптами и контроль состояния сервисов.
## Возможности
- **Сбор логов** — journald, Docker, Kubernetes, файлы
- **Метрики нагрузки** — CPU, RAM, диск, сеть в реальном времени
- **Контроль сервисов** — проверка alive/dead для systemd и Docker
- **Удалённое выполнение команд** — запуск скриптов и команд на агентах
- **Граф зависимостей** — определение причин сбоев, порядок запуска
- **Офлайн-буфер** — логи не теряются при потере связи
- **mTLS** — защищённое соединение между агентом и бэкендом
## Архитектура
```
┌─────────────────────────────────────────────────────────────┐
│ Инфраструктура │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Agent 1 │ │ Agent 2 │ │ Agent N │ │
│ │ (хост) │ │ (docker) │ │ (k8s) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ gRPC (mTLS) │
│ ▼ │
│ ┌───────────────┐ │
│ │ Backend │ ◄── REST API │
│ │ :8080 / :9001│ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ ClickHouse │ ◄── Хранилище логов │
│ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ HTTP
┌───────┴───────┐
│ Frontend │
│ :3000 │
└───────────────┘
```
## Быстрый старт
### Требования
- Docker + Docker Compose
- Go 1.26.1+ (для локальной разработки)
### Деплой
```bash
cd infra
docker compose up -d --build
```
Поднимутся:
- **ClickHouse** — хранилище логов
- **Backend** — API (`8080`) + gRPC (`9001`)
- **Frontend** — веб-интерфейс (`3000`)
- **Agent** — пример агента
Откройте `http://localhost:3000`. Логин: `admin`, пароль: `admin123`.
Что бы агент заработал нужно в веб интрфейсе найти кнопку создать токен получить его и вписать в конфигурацию агента
### Локальная разработка
```bash
# Backend
cd backend && go run ./cmd/main.go
# Frontend
cd frontend && npm install && npm run dev
# Agent
cd agent && CONFIG_FILE=./config.yml go run main.go
```
## Конфигурация
### Backend
`infra/backend/config.yml`:
```yaml
database:
token_db: /var/lib/hellreign/tokens.db
clickhouse_host: clickhouse:9000
clickhouse_user: default
clickhouse_password: testpassword
clickhouse_database: hellreign
admin:
admin_name: Admin
admin_last_name: User
admin_login: admin
admin_password: admin123
```
### Агент
`infra/agent/config.yml`:
```yaml
backend_url: http://backend:8080
grpc_url: backend:9001
label: production-server-1
registration_token: "token-из-ui"
cert_dir: /etc/hellreign-agent/certs
services:
# journald + проверка systemd
- name: nginx
type: journald
systemd_unit: nginx.service
# Docker контейнер
- name: redis
type: docker
# Файл
- name: myapp
type: file
path: /var/log/myapp/app.log
```
Поле `systemd_unit` опционально. Если указано — агент проверяет `systemctl is-active` и шлёт статус `up`/`down`. Для Docker — `docker inspect {{.State.Running}}`.
### Граф зависимостей
`infra/services.yaml`:
```yaml
nodes:
production-server-1:
services:
nginx:
depends_on: [sshd]
sshd:
depends_on: []
```
Используется для определения причины сбоев и порядка запуска.
## Переменные окружения
### Backend
| Переменная | По умолчанию | Описание |
|------------|-------------|----------|
| `CONFIG_FILE` | `/etc/hellreign/config.yml` | Путь к YAML конфигу |
| `GRAPH_YAML_PATH` | `/etc/hellreign/services.yaml` | Путь к графу сервисов |
| `SSL_CERT_DIR` | `/var/lib/hellreign/ssl` | Директория mTLS сертификатов |
| `SERVER_SAN_DNS` | `localhost,backend` | SAN DNS сертификата |
| `SERVER_SAN_IP` | `127.0.0.1` | SAN IP сертификата |
| `GRPC_PORT` | `9001` | Порт gRPC |
| `GIN_MODE` | `release` | Режим Gin |
### Агент
| Переменная | По умолчанию | Описание |
|------------|-------------|----------|
| `CONFIG_FILE` | `/etc/hellreign-agent/config.yml` | Путь к YAML конфигу |
| `JOURNALD_LOGDIR` | `/var/log/journal` | Директория journald (ro) |
| `BUFFER_DB` | `/var/lib/hellreign-agent/agent_buffer.db` | SQLite буфер |
| `IS_DEBUG` | `0` | Debug логи (`1`/`0`) |
## Порты
| Сервис | Порт | Назначение |
|--------|------|------------|
| Frontend | `3000` | Веб-интерфейс |
| Backend HTTP | `8080` | REST API + Swagger |
| Backend gRPC | `9001` | gRPC (mTLS) |
| ClickHouse HTTP | `8123` | HTTP интерфейс |
| ClickHouse Native | `9000` | Native протокол |
## API примеры
### Авторизация
```bash
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"login":"admin","password":"admin123"}'
```
### Агенты и метрики
```bash
# Список подключённых агентов
curl http://localhost:8080/api/v1/agents \
-H "Authorization: Bearer <jwt>"
# Метрики нагрузки (CPU, RAM, disk, network)
curl http://localhost:8080/api/v1/agents/system-metrics \
-H "Authorization: Bearer <jwt>"
```
### Логи
```bash
# Поиск
curl "http://localhost:8080/api/v1/logs?service=nginx&level=error" \
-H "Authorization: Bearer <jwt>"
# Список сервисов
curl http://localhost:8080/api/v1/logs/services \
-H "Authorization: Bearer <jwt>"
```
### Скрипты
```bash
# Дерево
curl http://localhost:8080/api/v1/scripts/tree \
-H "Authorization: Bearer <jwt>"
# Запуск на агенте
curl -X POST http://localhost:8080/api/v1/scripts/1/run \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"token":"agent-token"}'
```
### Swagger
`http://localhost:8080/swagger/index.html`
Перегенерация:
```bash
cd backend && swag init -g ./cmd/main.go --parseDependency --parseInternal
```
## Деплой агента на хост
### 1. Директории
```bash
sudo mkdir -p /etc/hellreign-agent/certs /var/lib/hellreign-agent
```
### 2. Конфиг
```bash
sudo nano /etc/hellreign-agent/config.yml
```
```yaml
backend_url: https://monitoring.example.com
grpc_url: monitoring.example.com:9001
label: prod-web-1
registration_token: "token-из-ui"
cert_dir: /etc/hellreign-agent/certs
services:
- name: nginx
type: journald
systemd_unit: nginx.service
- name: postgres
type: journald
systemd_unit: postgresql.service
```
### 3. Бинарь
Скачать из релиза Gitea или собрать:
```bash
cd agent && go build -o hellreign-agent ./main.go
sudo mv hellreign-agent /usr/bin/
```
### 4. Systemd
```ini
[Unit]
Description=HellreigN Agent
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/hellreign-agent
Environment=CONFIG_FILE=/etc/hellreign-agent/config.yml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now hellreign-agent
```
## CI/CD
При пуше тега `v*` — GoReleaser собирает `.deb` и `.rpm` для `linux/amd64` и `linux/arm64`:
```bash
git tag v1.0.0 && git push origin v1.0.0
```
Требует секрет `GITEA_TOKEN` в настройках репозитория.
## Proto
После изменений в `proto/hellreign.proto`:
```bash
cd proto
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
hellreign.proto
mv hellreign*.go proto/
```
## Структура
```
HellreigN/
├── agent/ # Агент диагностики
│ ├── main.go
│ └── internal/
│ ├── buffer/ # SQLite буфер (офлайн-доставка)
│ ├── client/ # gRPC клиент команд
│ ├── commander/ # Исполнитель команд
│ ├── config/ # YAML конфиг
│ ├── metrics/ # Сбор CPU, RAM, disk, network
│ ├── logsource/ # Источники логов
│ │ ├── docker/
│ │ ├── file/
│ │ ├── journald/
│ │ └── kubernetes/
│ ├── mtls/ # mTLS credentials
│ └── registration/ # Регистрация
├── backend/ # Бэкенд API
│ ├── cmd/main.go
│ └── internal/
│ ├── handlers/ # HTTP хендлеры
│ ├── repository/ # SQLite репозитории
│ ├── grpcsrv/
│ │ ├── commander/ # Выполнение команд
│ │ └── collector/ # Сбор логов и метрик
│ ├── auth/ # JWT
│ └── storage/ # ClickHouse
├── frontend/ # React + TypeScript
├── infra/ # Docker Compose
│ ├── docker-compose.yml
│ ├── services.yaml # Граф зависимостей
│ ├── backend/config.yml
│ ├── agent/config.yml
│ └── clickhouse/init/
├── migrations/ # SQL миграции SQLite
└── proto/ # Protobuf
```
+3 -20
View File
@@ -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
+4 -3
View File
@@ -8,9 +8,10 @@ import (
)
type ServiceConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Path *string `yaml:"path"`
Name string `yaml:"name"`
Type string `yaml:"type"`
Path *string `yaml:"path"`
SystemdUnit *string `yaml:"systemd_unit"` // Optional: systemd unit name for health check
}
type AgentConfig struct {
+48 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
@@ -329,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,
@@ -352,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,
})
}
@@ -377,6 +378,51 @@ 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,
+17 -7
View File
@@ -218,15 +218,25 @@ func main() {
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
}
// Service dependency graph (requires admin permission)
// Service dependency graph
graphGroup := v1.Group("/graph")
graphGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
graphGroup.Use(auth.AuthMiddleware())
{
graphGroup.GET("", graphHandlers.GetYAML)
graphGroup.PUT("", graphHandlers.UpdateYAML)
graphGroup.GET("/order", graphHandlers.StartupOrder)
graphGroup.GET("/cycle", graphHandlers.CycleCheck)
graphGroup.GET("/failure", graphHandlers.GetFailureRootCause)
// 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
+89 -5
View File
@@ -1001,19 +1001,20 @@ const docTemplate = `{
"Bearer": []
}
],
"description": "Returns the service dependency graph as raw YAML text",
"description": "Returns the service dependency graph as JSON",
"produces": [
"text/plain"
"application/json"
],
"tags": [
"graph"
],
"summary": "Get dependency graph YAML",
"summary": "Get dependency graph",
"responses": {
"200": {
"description": "YAML content",
"description": "Dependency graph",
"schema": {
"type": "string"
"type": "object",
"additionalProperties": true
}
}
}
@@ -1096,6 +1097,55 @@ const docTemplate = `{
}
}
},
"/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": [
@@ -2897,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": [
@@ -3061,6 +3128,23 @@ const docTemplate = `{
"type": "string"
}
}
},
"internal_handlers.ServiceStatusOut": {
"type": "object",
"properties": {
"healthy": {
"type": "boolean"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"status": {
"type": "string"
}
}
}
},
"securityDefinitions": {
+89 -5
View File
@@ -990,19 +990,20 @@
"Bearer": []
}
],
"description": "Returns the service dependency graph as raw YAML text",
"description": "Returns the service dependency graph as JSON",
"produces": [
"text/plain"
"application/json"
],
"tags": [
"graph"
],
"summary": "Get dependency graph YAML",
"summary": "Get dependency graph",
"responses": {
"200": {
"description": "YAML content",
"description": "Dependency graph",
"schema": {
"type": "string"
"type": "object",
"additionalProperties": true
}
}
}
@@ -1085,6 +1086,55 @@
}
}
},
"/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": [
@@ -2886,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": [
@@ -3050,6 +3117,23 @@
"type": "string"
}
}
},
"internal_handlers.ServiceStatusOut": {
"type": "object",
"properties": {
"healthy": {
"type": "boolean"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"status": {
"type": "string"
}
}
}
},
"securityDefinitions": {
+60 -5
View File
@@ -417,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:
@@ -527,6 +538,17 @@ definitions:
required:
- token
type: object
internal_handlers.ServiceStatusOut:
properties:
healthy:
type: boolean
name:
type: string
node_id:
type: string
status:
type: string
type: object
info:
contact: {}
paths:
@@ -1159,17 +1181,18 @@ paths:
- auth
/graph:
get:
description: Returns the service dependency graph as raw YAML text
description: Returns the service dependency graph as JSON
produces:
- text/plain
- application/json
responses:
"200":
description: YAML content
description: Dependency graph
schema:
type: string
additionalProperties: true
type: object
security:
- Bearer: []
summary: Get dependency graph YAML
summary: Get dependency graph
tags:
- graph
put:
@@ -1220,6 +1243,38 @@ paths:
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
@@ -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})
@@ -51,6 +55,9 @@ func (c *Collector) ReportSystemMetrics(ctx context.Context, req *proto.SystemMe
}
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,
@@ -68,3 +75,18 @@ func (c *Collector) ReportSystemMetrics(ctx context.Context, req *proto.SystemMe
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)
}
}
+36 -9
View File
@@ -53,25 +53,52 @@ func (h *GraphHandlers) reload() error {
return nil
}
// GetGraph returns the current parsed graph.
func (h *GraphHandlers) GetGraph() *graph.Graph {
// LoadedGraph returns the current parsed graph.
func (h *GraphHandlers) LoadedGraph() *graph.Graph {
h.mu.RLock()
defer h.mu.RUnlock()
return h.loaded
}
// GetYAML returns the raw YAML content.
// @Summary Get dependency graph YAML
// @Description Returns the service dependency graph as raw YAML text
// GetGraph returns the current dependency graph as JSON.
// @Summary Get dependency graph
// @Description Returns the service dependency graph as JSON
// @Tags graph
// @Produce plain
// @Success 200 {string} string "YAML content"
// @Produce json
// @Success 200 {object} map[string]interface{} "Dependency graph"
// @Security Bearer
// @Router /graph [get]
func (h *GraphHandlers) GetYAML(c *gin.Context) {
func (h *GraphHandlers) GetGraph(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
c.Data(http.StatusOK, "text/yaml", h.yamlData)
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.
+3 -1
View File
@@ -7,7 +7,9 @@
"Bash(type *)",
"Bash(dir)",
"Bash(move *)",
"Bash(findstr *)"
"Bash(findstr *)",
"Bash(del *)",
"Bash(mkdir *)"
]
},
"$version": 3
+7152
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -11,19 +11,24 @@
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.2.2",
"@uiw/react-codemirror": "^4.25.8",
"axios": "^1.13.6",
"file-surf": "^1.0.3",
"framer-motion": "^12.38.0",
"monaco-languageclient": "^10.7.0",
"primeicons": "^7.0.0",
"primereact": "^10.9.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-2d": "^1.29.1",
"react-icons": "^5.6.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwind": "^4.0.0",
"tailwindcss": "^4.2.2",
"vscode-ws-jsonrpc": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
+13
View File
@@ -1,11 +1,24 @@
import { useState, useEffect } from "react";
import "@/shared/styles/index.css";
import "primereact/resources/themes/lara-light-cyan/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
import { PrimeReactProvider } from "primereact/api";
import { Routing } from "./providers/routing/routing";
import { AppLoader } from "./components/AppLoader";
function App() {
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 1800);
return () => clearTimeout(timer);
}, []);
if (loading) {
return <AppLoader />;
}
return (
<PrimeReactProvider>
<Routing />
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useState } from "react";
import { FaMicrochip, FaCode, FaNetworkWired, FaAtom } from "react-icons/fa";
export const AppLoader = () => {
const [progress, setProgress] = useState(0);
const [phase, setPhase] = useState(0);
useEffect(() => {
const phases = [
{ progress: 25, delay: 400 },
{ progress: 50, delay: 300 },
{ progress: 75, delay: 400 },
{ progress: 100, delay: 300 },
];
let timeouts: NodeJS.Timeout[] = [];
let currentDelay = 0;
phases.forEach((p, i) => {
currentDelay += p.delay;
timeouts.push(
setTimeout(() => {
setProgress(p.progress);
setPhase(i);
}, currentDelay),
);
});
return () => timeouts.forEach(clearTimeout);
}, []);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#0a0a0f",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 9999,
overflow: "hidden",
}}
>
{/* Background grid effect */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
animation: "gridMove 20s linear infinite",
}}
/>
{/* Glowing orbs */}
<div
style={{
position: "absolute",
width: "300px",
height: "300px",
borderRadius: "50%",
background:
"radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)",
filter: "blur(40px)",
animation: "orbFloat 6s ease-in-out infinite",
top: "20%",
left: "30%",
}}
/>
<div
style={{
position: "absolute",
width: "250px",
height: "250px",
borderRadius: "50%",
background:
"radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)",
filter: "blur(40px)",
animation: "orbFloat 8s ease-in-out infinite reverse",
bottom: "20%",
right: "30%",
}}
/>
{/* Main content */}
<div style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
{/* Logo with animation */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "16px",
marginBottom: "40px",
}}
>
<div
style={{
animation: "logoSpin 3s ease-in-out infinite",
}}
>
<FaAtom size={48} style={{ color: "#3b82f6" }} />
</div>
<h1
style={{
fontSize: "42px",
fontWeight: 800,
background:
"linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "4px",
animation: "titleGlow 2s ease-in-out infinite",
}}
>
HellreigN
</h1>
</div>
{/* Loading icons animation */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "24px",
marginBottom: "40px",
}}
>
{[
{ icon: FaMicrochip, delay: "0s" },
{ icon: FaNetworkWired, delay: "0.2s" },
{ icon: FaCode, delay: "0.4s" },
].map(({ icon: Icon, delay }, i) => (
<div
key={i}
style={{
width: "50px",
height: "50px",
borderRadius: "12px",
border: `2px solid ${
phase >= i
? "rgba(59, 130, 246, 0.6)"
: "rgba(255,255,255,0.1)"
}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor:
phase >= i ? "rgba(59, 130, 246, 0.1)" : "transparent",
animation: `iconPop 0.5s ease-out ${delay} both`,
transition: "all 0.3s ease",
}}
>
<Icon
size={22}
style={{
color: phase >= i ? "#3b82f6" : "#555",
transition: "color 0.3s ease",
}}
/>
</div>
))}
</div>
{/* Progress bar */}
<div
style={{
width: "320px",
height: "4px",
backgroundColor: "rgba(255,255,255,0.1)",
borderRadius: "2px",
overflow: "hidden",
marginBottom: "16px",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background:
"linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
borderRadius: "2px",
transition: "width 0.4s ease",
boxShadow: "0 0 20px rgba(59, 130, 246, 0.5)",
}}
/>
</div>
{/* Status text */}
<div
style={{
fontSize: "13px",
color: "rgba(255,255,255,0.5)",
fontFamily: "monospace",
letterSpacing: "2px",
}}
>
{phase === 0 && "INITIALIZING CORE..."}
{phase === 1 && "LOADING AGENTS..."}
{phase === 2 && "ESTABLISHING CONNECTIONS..."}
{phase === 3 && "READY"}
</div>
</div>
{/* CSS Animations */}
<style>{`
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(40px, 40px); }
}
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, -30px) scale(1.1); }
}
@keyframes logoSpin {
0%, 100% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(-10deg) scale(1.05); }
75% { transform: rotate(10deg) scale(1.05); }
}
@keyframes titleGlow {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.3); }
}
@keyframes iconPop {
0% { transform: scale(0.5) translateY(10px); opacity: 0; }
100% { transform: scale(1) translateY(0); opacity: 1; }
}
`}</style>
</div>
);
};
@@ -0,0 +1,75 @@
import { useState, useEffect, type ReactNode } from "react";
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
import {
Navigation,
BottomNav,
} from "@/app/providers/layout/navigation/navigation";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
export const Layout = ({ children }: { children: ReactNode }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 856 : false,
);
const [isVerySmall, setIsVerySmall] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 600 : false,
);
const { fetchAgents } = useAgentStore();
const sidebarOpen = isMobile ? mobileOpen : true;
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 856;
setIsMobile(mobile);
if (!mobile) {
setMobileOpen(false);
}
setIsVerySmall(window.innerWidth < 600);
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, []);
const toggleSidebar = () => {
if (isMobile) {
setMobileOpen((prev) => !prev);
}
};
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
useEffect(() => {
const interval = setInterval(() => {
fetchAgents();
}, 30000);
return () => clearInterval(interval);
}, [fetchAgents]);
return (
<div
className="flex h-screen overflow-hidden"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<Sidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Navigation
onToggleSidebar={toggleSidebar}
isMobile={isMobile}
isVerySmall={isVerySmall}
/>
<div className="flex-1 overflow-auto p-4">{children}</div>
{isVerySmall && <BottomNav />}
</div>
</div>
);
};
@@ -0,0 +1,445 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
import {
FaHome,
FaServer,
FaUser,
FaUsers,
FaRocket,
FaKey,
FaFileAlt,
FaPalette,
FaSignOutAlt,
FaShieldAlt,
} from "react-icons/fa";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
import { themes } from "@/modules/theme-changer/config/theme.config";
import {
applyTheme,
getCurrentTheme,
} from "@/modules/theme-changer/utils/apply.theme";
interface NavigationProps {
onToggleSidebar?: () => void;
isMobile?: boolean;
isVerySmall?: boolean;
}
export const Navigation: React.FC<NavigationProps> = ({
onToggleSidebar,
isMobile,
isVerySmall = false,
}) => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { setTheme } = useThemeStore();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [themePickerOpen, setThemePickerOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const currentTheme = getCurrentTheme();
const navItems = [
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
{
path: "/add-agents",
label: "Деплой",
icon: FaRocket,
requireManageAgent: true,
},
{
path: "/registration",
label: "Регистрация",
icon: FaKey,
requireManageAgent: true,
},
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
];
const isActive = (path: string) => location.pathname === path;
// Filter nav items based on user permissions
const filteredNavItems = navItems.filter((item) => {
if (item.requireView && !user?.permission_view) return false;
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
return true;
});
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
setThemePickerOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = () => {
logout();
navigate("/auth");
};
const handleThemeChange = (themeId: string) => {
applyTheme(themeId);
setTheme(themeId as any);
setThemePickerOpen(false);
};
const renderNavItems = (showLabels: boolean, iconSize: number) => (
<div className="flex items-center gap-1 whitespace-nowrap">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg font-medium transition-all flex-shrink-0"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active ? "var(--accent-text)" : "var(--text-secondary)",
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
}
}}
title={item.label}
>
<Icon size={iconSize} />
{showLabels && <span className="text-xs">{item.label}</span>}
</button>
);
})}
</div>
);
return (
<>
{/* Верхний бар */}
<div
className="flex-shrink-0 border-b"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between px-4 py-2.5">
{/* Бургер — только на мобильных */}
{isMobile && (
<button
onClick={onToggleSidebar}
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
aria-label="Открыть sidebar"
>
<FaBars size={14} />
</button>
)}
{/* Название по центру — только на очень маленьких экранах */}
{isVerySmall && (
<div className="flex-1 text-center mx-4">
<span
className="text-sm font-bold"
style={{ color: "var(--text-primary)" }}
>
HellreigN
</span>
</div>
)}
{/* Навигация — только если НЕ очень маленький экран */}
{!isVerySmall && (
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
{renderNavItems(true, 12)}
</div>
)}
{/* Профиль пользователя — дропдаун */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
style={{
backgroundColor: dropdownOpen
? "var(--bg-secondary)"
: "transparent",
border: "1px solid var(--border)",
}}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
</div>
<span
className="text-xs font-medium"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login || "Пользователь"}
</span>
<FaChevronDown
size={10}
style={{
color: "var(--text-secondary)",
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{dropdownOpen && (
<div
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "220px",
}}
>
<div
className="px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2 mb-1">
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--accent)" }}
>
<FaUser
size={12}
style={{ color: "var(--accent-text)" }}
/>
</div>
<div>
<p
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{user?.name || user?.login}
</p>
<p
className="text-[10px]"
style={{ color: "var(--text-muted)" }}
>
{user?.login}
</p>
</div>
</div>
</div>
<div className="relative">
<button
onClick={() => setThemePickerOpen(!themePickerOpen)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaPalette
size={12}
style={{ color: "var(--text-secondary)" }}
/>
<span className="flex-1 text-left">
Тема: {themes.find((t) => t.id === currentTheme)?.name}
</span>
<FaChevronDown
size={9}
style={{
color: "var(--text-muted)",
transform: themePickerOpen
? "rotate(180deg)"
: "rotate(0)",
transition: "transform 0.2s",
}}
/>
</button>
{themePickerOpen && (
<div
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
minWidth: "180px",
}}
>
{themes.map((t) => (
<button
key={t.id}
onClick={() => handleThemeChange(t.id)}
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
style={{
color:
currentTheme === t.id
? "var(--accent)"
: "var(--text-primary)",
backgroundColor:
currentTheme === t.id
? "var(--bg-secondary)"
: "transparent",
}}
onMouseEnter={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}
}}
onMouseLeave={(e) => {
if (currentTheme !== t.id) {
e.currentTarget.style.backgroundColor =
"transparent";
}
}}
>
<div
className="w-4 h-4 rounded-full border"
style={{
backgroundColor: t.colors.primary,
borderColor: "var(--border)",
}}
/>
<span>{t.name}</span>
</button>
))}
</div>
)}
</div>
{user?.permission_admin && (
<button
onClick={() => {
setDropdownOpen(false);
navigate("/admin");
}}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
<span>Админка</span>
</button>
)}
<div
className="my-1 border-b"
style={{ borderColor: "var(--border)" }}
/>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
style={{ color: "var(--error-text)" }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"rgba(239, 68, 68, 0.1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FaSignOutAlt size={12} />
<span>Выйти</span>
</button>
</div>
)}
</div>
</div>
</div>
</>
);
};
export const BottomNav: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const navItems = [
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
{
path: "/add-agents",
label: "Деплой",
icon: FaRocket,
requireManageAgent: true,
},
{
path: "/registration",
label: "Регистрация",
icon: FaKey,
requireManageAgent: true,
},
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
];
const isActive = (path: string) => location.pathname === path;
// Filter nav items based on user permissions
const filteredNavItems = navItems.filter((item) => {
if (item.requireView && !user?.permission_view) return false;
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
return true;
});
return (
<div
className="flex-shrink-0 border-t"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-around px-2 py-2">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.path);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="flex items-center justify-center p-3 rounded-lg transition-all"
style={{
backgroundColor: active ? "var(--accent)" : "transparent",
color: active ? "var(--accent-text)" : "var(--text-secondary)",
}}
title={item.label}
>
<Icon size={20} />
</button>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,717 @@
import React, { useMemo, useState, useRef, useEffect } from "react";
import {
FaBars,
FaMicrochip,
FaTimes,
FaSpinner,
FaCopy,
FaCheck,
FaChevronRight,
FaChevronDown,
FaProjectDiagram,
FaTrash,
FaArrowLeft,
} from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { Graph, type GraphData } from "@/modules/graph";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import { adminApi } from "@/modules/admin/api/admin.api";
interface SidebarProps {
isOpen?: boolean;
onToggle?: () => void;
isMobile?: boolean;
}
export const Sidebar: React.FC<SidebarProps> = ({
isOpen = true,
onToggle,
isMobile = false,
}) => {
const navigate = useNavigate();
const { agents, isLoading, error, fetchAgents, removeAgent } =
useAgentStore();
const { token } = useAuthStore();
const [searchQuery, setSearchQuery] = useState("");
const [copied, setCopied] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false);
const [showGraphs, setShowGraphs] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(288);
const sidebarRef = useRef<HTMLDivElement>(null);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
new Set(agents.map((a) => a.label)),
);
// Рассчитываем максимальную ширину при переключении на графы
useEffect(() => {
const updateWidth = () => {
const targetWidth = showGraphs ? 500 : 288;
const maxWidth = window.innerWidth - 200;
const finalWidth = Math.min(targetWidth, maxWidth);
setSidebarWidth(Math.max(finalWidth, 250));
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}, [showGraphs]);
// Token generation state
const [tokenLabel, setTokenLabel] = useState("");
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [tokenGenerating, setTokenGenerating] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const toggleAgent = (label: string) => {
setExpandedAgents((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
};
const filteredAgents = useMemo(() => {
if (!searchQuery) return agents;
const query = searchQuery.toLowerCase();
return agents.filter(
(agent) =>
agent.label.toLowerCase().includes(query) ||
agent.services.some((s) => s.toLowerCase().includes(query)),
);
}, [agents, searchQuery]);
const [graphData, setGraphData] = useState<GraphData>({
nodes: [],
links: [],
});
useEffect(() => {
const fetchGraph = () => {
agentApiService
.getGraph()
.then((apiData) => {
const nodes: any[] = [];
const links: any[] = [];
// Build a map of service statuses from agents
const serviceStatusMap = new Map<string, "up" | "down">();
agents.forEach((agent) => {
const services = agent.services || [];
services.forEach((svc: string) => {
const parts = svc.split(":");
const svcName = parts[0];
const status = parts[1] === "down" ? "down" : "up";
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
});
});
Object.entries(apiData.nodes || {}).forEach(
([agentLabel, agentNode]: [string, any]) => {
nodes.push({
id: agentLabel,
name: agentLabel,
type: "agent" as const,
val: 8,
description: `Агент: ${agentLabel}`,
});
const services = agentNode?.services || {};
Object.entries(services).forEach(
([serviceName, serviceNode]: [string, any]) => {
const serviceId = `${agentLabel}-${serviceName}`;
const status = serviceStatusMap.get(serviceId) || "up";
nodes.push({
id: serviceId,
name: serviceName,
type: "service" as const,
val: 12,
description: `Сервис: ${serviceName}`,
status,
});
links.push({
source: agentLabel,
target: serviceId,
type: "hosts",
});
const dependencies = serviceNode?.dependencies || [];
dependencies.forEach((dep: any) => {
const targetName = dep?.target?.name;
if (targetName) {
links.push({
source: serviceId,
target: `${agentLabel}-${targetName}`,
type: dep.condition || "dependency",
});
}
});
},
);
},
);
setGraphData({ nodes, links });
})
.catch((e) => {
console.error("Failed to fetch graph:", e);
});
};
fetchGraph();
const interval = setInterval(fetchGraph, 30000);
return () => clearInterval(interval);
}, [agents]);
const handleCopyToken = () => {
const tokenToCopy = generatedToken || token;
if (tokenToCopy) {
navigator.clipboard.writeText(tokenToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleGenerateToken = async () => {
if (!tokenLabel.trim()) return;
setTokenGenerating(true);
setTokenError(null);
try {
const newToken = await adminApi.generateToken(tokenLabel.trim());
setGeneratedToken(newToken);
} catch (e) {
setTokenError(
e instanceof Error ? e.message : "Failed to generate token",
);
} finally {
setTokenGenerating(false);
}
};
const handleCloseTokenModal = () => {
setShowTokenModal(false);
setTokenLabel("");
setGeneratedToken(null);
setTokenError(null);
setCopied(false);
};
if (!isOpen) {
return null;
}
return (
<>
{/* Overlay — только на мобильных (< 856px) */}
{isMobile && (
<div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
)}
<aside
ref={sidebarRef}
className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
style={{
width: `${sidebarWidth}px`,
height: "100vh",
backgroundColor: "var(--card-bg)",
borderRight: "1px solid var(--border)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2">
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
<h2
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Агенты
</h2>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
>
{agents.length}
</span>
</div>
<button
onClick={onToggle}
className={`p-1 rounded transition-colors ${isMobile ? "" : "hidden"}`}
style={{ color: "var(--text-secondary)" }}
aria-label="Закрыть sidebar"
>
<FaTimes size={14} />
</button>
</div>
{/* Контент — либо список агентов, либо графы */}
{showGraphs ? (
<div className="flex-1 overflow-hidden relative">
<Graph initialData={graphData} />
</div>
) : (
<>
{/* Поиск */}
<div className="px-3 py-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск агентов..."
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
style={{
backgroundColor: "var(--input-bg)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = "var(--border-focus)";
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.boxShadow = "none";
}}
/>
</div>
{/* Список агентов */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{isLoading && agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<FaSpinner
className="animate-spin mb-3"
style={{ color: "var(--accent)", fontSize: "20px" }}
/>
<p
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
Загрузка агентов...
</p>
</div>
) : error ? (
<div className="text-center py-8">
<div
className="text-xs mb-2"
style={{ color: "var(--error-text)" }}
>
{error}
</div>
<button
onClick={fetchAgents}
className="text-xs hover:underline"
style={{ color: "var(--accent)" }}
>
Попробовать снова
</button>
</div>
) : filteredAgents.length === 0 ? (
<div
className="text-center py-8"
style={{ color: "var(--text-muted)" }}
>
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
<p className="text-xs">
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
</p>
</div>
) : (
<div className="space-y-1">
{filteredAgents.map((agent) => {
const isExpanded = expandedAgents.has(agent.label);
return (
<div
key={agent.label}
className="rounded-lg border overflow-hidden transition-all group"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
{/* Agent header — кликабельный для сворачивания */}
<div
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => toggleAgent(agent.label)}
>
<span style={{ color: "var(--text-muted)" }}>
{isExpanded ? (
<FaChevronDown size={10} />
) : (
<FaChevronRight size={10} />
)}
</span>
<FaMicrochip
size={12}
style={{ color: "var(--accent)" }}
/>
<span
className="text-sm font-medium flex-1 truncate cursor-pointer"
style={{ color: "var(--text-primary)" }}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/${agent.label}`);
}}
title="Открыть дашборд агента"
>
{agent.label}
</span>
{/* Статус-индикатор агента (количество сервисов) */}
<div className="flex items-center gap-1">
{agent.services.length > 0 && (
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: "#4ade80" }}
/>
)}
<span
className="text-[10px]"
style={{ color: "var(--text-muted)" }}
>
{agent.services.length}
</span>
</div>
{/* Кнопка удаления — появляется при наведении */}
<button
onClick={(e) => {
e.stopPropagation();
if (
window.confirm(
`Удалить агента "${agent.label}"?`,
)
) {
removeAgent(agent.label);
}
}}
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all flex-shrink-0"
style={{
color: "var(--text-muted)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#f87171";
e.currentTarget.style.backgroundColor =
"rgba(248, 113, 113, 0.15)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.backgroundColor =
"transparent";
}}
title="Удалить агента"
>
<FaTrash size={10} />
</button>
</div>
{/* Services list — сворачивается */}
{isExpanded && (
<div
className="px-3 pb-2"
style={{ paddingLeft: "24px" }}
>
<div
className="border-l-2 pl-3 space-y-1"
style={{ borderColor: "var(--border)" }}
>
{agent.services.map((service) => {
// Parse "serviceName:up" or "serviceName:down"
const parts = service.split(":");
const serviceName = parts[0];
const isDown = parts[1] === "down";
return (
<div
key={service}
className="flex items-center justify-between py-1"
>
<span
className="text-xs"
style={{
color: isDown
? "#ef4444"
: "var(--text-secondary)",
}}
>
{serviceName}
</span>
{/* Status indicator */}
<div className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{
backgroundColor: isDown
? "#ef4444"
: "#4ade80",
}}
/>
<span
className="text-[10px] font-medium"
style={{
color: isDown ? "#ef4444" : "#4ade80",
}}
>
{isDown ? "down" : "run"}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</>
)}
{/* Footer с кнопками */}
<div
className="p-2 border-t flex gap-2"
style={{
borderColor: "var(--border)",
backgroundColor: "var(--card-bg)",
}}
>
{showGraphs ? (
/* Кнопка назад к агентам */
<button
onClick={() => setShowGraphs(false)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
}}
>
<FaArrowLeft size={10} />К агентам
</button>
) : (
/* Кнопка Графы */
<button
onClick={() => setShowGraphs(true)}
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
}}
>
<FaProjectDiagram size={10} />
Графы
</button>
)}
<button
onClick={() => setShowTokenModal(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
}}
>
<FaCopy size={10} />
Токен
</button>
</div>
</aside>
{/* Modal токена */}
{showTokenModal && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={handleCloseTokenModal}
>
<div
className="w-full max-w-md rounded-xl shadow-2xl border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2">
<FaCopy style={{ color: "var(--accent)" }} size={14} />
<h2
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Генерация токена
</h2>
</div>
<button
onClick={handleCloseTokenModal}
className="p-1 rounded transition-colors"
style={{ color: "var(--text-secondary)" }}
>
<FaTimes size={14} />
</button>
</div>
<div className="p-4 space-y-3">
{/* Error */}
{tokenError && (
<div
className="text-xs p-2 rounded"
style={{
backgroundColor: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "var(--error-text, #ef4444)",
}}
>
{tokenError}
</div>
)}
{/* Label input */}
{!generatedToken && (
<div>
<label
className="block text-xs font-medium mb-2"
style={{ color: "var(--text-secondary)" }}
>
Имя токена
</label>
<input
type="text"
value={tokenLabel}
onChange={(e) => setTokenLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && tokenLabel.trim()) {
handleGenerateToken();
}
}}
placeholder="Введите имя..."
autoFocus
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
style={{
backgroundColor: "var(--input-bg)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</div>
)}
{/* Generated token */}
{generatedToken && (
<div>
<label
className="block text-xs font-medium mb-2"
style={{ color: "var(--text-secondary)" }}
>
Токен
</label>
<div
className="flex items-center gap-2 rounded-lg p-3 border"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<code
className="flex-1 text-xs font-mono break-all"
style={{ color: "var(--text-primary)" }}
>
{generatedToken}
</code>
<button
onClick={handleCopyToken}
className="p-1.5 rounded transition-colors"
style={{ color: "var(--text-secondary)" }}
>
{copied ? (
<FaCheck
size={12}
style={{ color: "var(--success-text)" }}
/>
) : (
<FaCopy size={12} />
)}
</button>
</div>
</div>
)}
{/* Buttons */}
<div className="flex gap-2">
{generatedToken && (
<button
onClick={() => {
setGeneratedToken(null);
setTokenLabel("");
}}
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border)",
}}
>
Новый токен
</button>
)}
<button
onClick={
generatedToken ? handleCloseTokenModal : handleGenerateToken
}
disabled={tokenGenerating || !tokenLabel.trim()}
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor:
tokenGenerating || (!generatedToken && !tokenLabel.trim())
? "var(--bg-secondary)"
: "var(--accent)",
color:
tokenGenerating || (!generatedToken && !tokenLabel.trim())
? "var(--text-muted)"
: "var(--accent-text)",
cursor:
tokenGenerating || (!generatedToken && !tokenLabel.trim())
? "default"
: "pointer",
}}
>
{tokenGenerating
? "Генерация..."
: generatedToken
? "Готово"
: "Создать"}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,35 @@
import { create } from "zustand";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import type { AgentInfo } from "@/modules/agent/types/agent.types";
interface AgentState {
agents: AgentInfo[];
isLoading: boolean;
error: string | null;
fetchAgents: () => Promise<void>;
removeAgent: (name: string) => void;
}
export const useAgentStore = create<AgentState>()((set, get) => ({
agents: [],
isLoading: false,
error: null,
fetchAgents: async () => {
set({ isLoading: true, error: null });
try {
const agents = await agentApiService.getAgents();
set({ agents, isLoading: false });
} catch (error) {
set({
error:
error instanceof Error ? error.message : "Failed to fetch agents",
isLoading: false,
});
}
},
removeAgent: (name: string) => {
set({ agents: get().agents.filter((a) => a.label !== name) });
},
}));
@@ -0,0 +1,50 @@
import { create } from "zustand";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import type { SystemMetrics } from "@/modules/agent/types/agent.types";
interface MetricsState {
metrics: SystemMetrics[];
isLoading: boolean;
error: string | null;
lastUpdated: number | null;
}
const POLLING_INTERVAL = 30_000;
let _pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useMetricsStore = create<MetricsState>(() => ({
metrics: [],
isLoading: false,
error: null,
lastUpdated: null,
}));
export const startMetricsPolling = async () => {
if (_pollingTimer) return;
const fetchMetrics = async () => {
try {
const data = await agentApiService.getSystemMetrics();
useMetricsStore.setState({
metrics: data,
isLoading: false,
error: null,
lastUpdated: Date.now(),
});
} catch (e) {
useMetricsStore.setState({
error: e instanceof Error ? e.message : "Failed to fetch metrics",
isLoading: false,
});
}
};
await fetchMetrics();
_pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL);
};
export const stopMetricsPolling = () => {
if (_pollingTimer) {
clearInterval(_pollingTimer);
_pollingTimer = null;
}
};
@@ -1,12 +1,42 @@
import { useAuthStore } from "@/store/auth/auth.store";
import { Navigate } from "react-router-dom";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useAuthStore();
interface ProtectedRouteProps {
children: React.ReactNode;
requireView?: boolean;
requireManageAgent?: boolean;
requireAdmin?: boolean;
fallbackPath?: string;
}
if (!isAuthenticated) {
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requireView = false,
requireManageAgent = false,
requireAdmin = false,
fallbackPath = "/",
}) => {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated && user?.token) {
// User is authenticated based on token
}
if (!user) {
return <Navigate to="/auth" replace />;
}
if (requireView && !user.permission_view) {
return <Navigate to={fallbackPath} replace />;
}
if (requireManageAgent && !user.permission_manage_agent) {
return <Navigate to={fallbackPath} replace />;
}
if (requireAdmin && !user.permission_admin) {
return <Navigate to={fallbackPath} replace />;
}
return <>{children}</>;
};
+190 -9
View File
@@ -1,11 +1,113 @@
import { Suspense } from "react";
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
import { HomePage } from "@/pages/home.page";
import { ThemesPage } from "@/pages/themes.page";
import { TestPage } from "@/pages/test.page";
import { Graph, type GraphData } from "@/modules/graph";
import { AuthPage } from "@/pages/auth.page";
import { RegisterPage } from "@/pages/register.page";
import { AddAgentsPage } from "@/pages/add-agents.page";
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
import { AddAgentsPage } from "@/pages/add-agents.page";
import { IDEPage } from "@/pages/ide.page";
import { TemplatesPage } from "@/pages/templates.page";
import { AdminPage } from "@/pages/admin.page";
import { RegistrationTokenPage } from "@/pages/registration.page";
import { LogsPage } from "@/pages/logs.page";
import { GraphsPage } from "@/pages/graphs.page";
import { DashboardPage } from "@/pages/dashboard.page";
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
import { ProtectedRoute } from "./helper/protected.route";
export const mockGraphData: GraphData = {
nodes: [
{
id: "api-gateway",
name: "API Gateway",
type: "service",
val: 12,
description: "Входная точка API",
},
{
id: "auth-service",
name: "Auth Service",
type: "service",
val: 12,
description: "Аутентификация",
},
{
id: "db-service",
name: "Database",
type: "service",
val: 12,
description: "Хранилище данных",
},
{
id: "redis-service",
name: "Redis",
type: "service",
val: 12,
description: "Кэширование",
},
{
id: "queue-service",
name: "Message Queue",
type: "service",
val: 12,
description: "Очередь сообщений",
},
{
id: "user-agent",
name: "User Agent",
type: "agent",
val: 8,
description: "Обработка пользователей",
},
{
id: "payment-agent",
name: "Payment Agent",
type: "agent",
val: 8,
description: "Платежи",
},
{
id: "notification-agent",
name: "Notification Agent",
type: "agent",
val: 8,
description: "Уведомления",
},
{
id: "analytics-agent",
name: "Analytics Agent",
type: "agent",
val: 8,
description: "Аналитика",
},
{
id: "report-agent",
name: "Report Agent",
type: "agent",
val: 8,
description: "Отчеты",
},
],
links: [
{ source: "user-agent", target: "api-gateway", type: "uses" },
{ source: "user-agent", target: "auth-service", type: "uses" },
{ source: "user-agent", target: "db-service", type: "uses" },
{ source: "payment-agent", target: "api-gateway", type: "uses" },
{ source: "payment-agent", target: "auth-service", type: "uses" },
{ source: "payment-agent", target: "queue-service", type: "uses" },
{ source: "notification-agent", target: "redis-service", type: "uses" },
{ source: "notification-agent", target: "queue-service", type: "uses" },
{ source: "analytics-agent", target: "db-service", type: "uses" },
{ source: "report-agent", target: "db-service", type: "uses" },
{ source: "report-agent", target: "redis-service", type: "uses" },
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
{ source: "auth-service", target: "db-service", type: "depends_on" },
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
{ source: "queue-service", target: "redis-service", type: "depends_on" },
],
};
export const Routing = () => {
return (
@@ -17,15 +119,94 @@ export const Routing = () => {
}
>
<ReactRoutes>
<Route element={<DefaultLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/themes" element={<ThemesPage />} />
<Route path="/add-agents" element={<AddAgentsPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route element={<DefaultLayout />}>
{/* Routes requiring 'view' permission */}
<Route
path="/"
element={
<ProtectedRoute requireView>
<TemplatesPage />
</ProtectedRoute>
}
/>
<Route
path="/logs"
element={
<ProtectedRoute requireView>
<LogsPage />
</ProtectedRoute>
}
/>
<Route
path="/graphs"
element={
<ProtectedRoute requireView>
<GraphsPage />
</ProtectedRoute>
}
/>
<Route
path="/dashboard/:agentLabel"
element={
<ProtectedRoute requireView>
<AgentDashboardPage />
</ProtectedRoute>
}
/>
{/* Routes requiring 'manage_agent' permission */}
<Route
path="/add-agents"
element={
<ProtectedRoute requireManageAgent>
<AddAgentsPage />
</ProtectedRoute>
}
/>
<Route
path="/registration"
element={
<ProtectedRoute requireManageAgent>
<RegistrationTokenPage />
</ProtectedRoute>
}
/>
<Route
path="/templates"
element={
<ProtectedRoute requireView>
<TemplatesPage />
</ProtectedRoute>
}
/>
<Route
path="/IDE"
element={
<ProtectedRoute requireView>
<IDEPage />
</ProtectedRoute>
}
/>
{/* Admin route requiring 'admin' permission */}
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="/test" element={<TestPage />} />
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</ReactRoutes>
</Suspense>
);
+166
View File
@@ -0,0 +1,166 @@
import React, { useEffect, useState } from "react";
import {
FaUsers,
FaShieldAlt,
FaSpinner,
FaExclamationCircle,
FaPlus,
} from "react-icons/fa";
import { useAdminStore } from "./store/useAdminStore";
import { UserCard } from "./components/UserCard";
import { CreateUserModal } from "./components/CreateUserModal";
export const AdminPanel: React.FC = () => {
const users = useAdminStore((s) => s.users);
const loading = useAdminStore((s) => s.loading);
const error = useAdminStore((s) => s.error);
const fetchUsers = useAdminStore((s) => s.fetchUsers);
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
fetchUsers();
}, []);
const activeCount = users.filter((u) => u.is_active).length;
return (
<div style={{ padding: "24px", maxWidth: "900px", margin: "0 auto" }}>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "24px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
width: "40px",
height: "40px",
borderRadius: "8px",
backgroundColor: "var(--accent)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FaShieldAlt size={18} style={{ color: "var(--accent-text)" }} />
</div>
<div>
<h1
style={{
fontSize: "18px",
fontWeight: 600,
color: "var(--text-primary)",
margin: 0,
}}
>
Управление пользователями
</h1>
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
{loading
? "Загрузка..."
: `${activeCount} / ${users.length} активных`}
</span>
</div>
</div>
<button
onClick={() => setShowCreateModal(true)}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "8px 16px",
backgroundColor: "var(--accent)",
color: "var(--accent-text)",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "13px",
fontWeight: 500,
}}
>
<FaPlus size={12} />
Добавить
</button>
</div>
{/* Error */}
{error && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "12px",
backgroundColor: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: "8px",
color: "var(--error-text, #ef4444)",
marginBottom: "16px",
}}
>
<FaExclamationCircle />
<span style={{ fontSize: "13px" }}>{error}</span>
</div>
)}
{/* Loading */}
{loading && users.length === 0 && (
<div
style={{
display: "flex",
justifyContent: "center",
padding: "60px 0",
}}
>
<FaSpinner
className="animate-spin"
size={24}
style={{ color: "var(--accent)" }}
/>
</div>
)}
{/* Users list */}
{!loading && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
)}
{/* Empty state */}
{!loading && users.length === 0 && (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-muted)",
}}
>
<p style={{ fontSize: "14px" }}>
Нет зарегистрированных пользователей
</p>
</div>
)}
{/* Create user modal */}
<CreateUserModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
</div>
);
};
@@ -0,0 +1,97 @@
import { apiClient } from "@/shared/api/axios.instance";
const getAuthHeader = () => {
const raw = localStorage.getItem("auth-storage");
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed?.state?.token) return `bearer ${parsed.state.token}`;
} catch {}
}
return "";
};
export interface AdminUserDto {
id: number;
login: string;
name: string;
last_name: string;
is_active: boolean;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
token: string;
}
export interface CreateUserPayload {
login: string;
name: string;
last_name: string;
password: string;
is_active: boolean;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
}
export interface PermissionsPayload {
is_active: boolean;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
}
export const adminApi = {
getUsers: async (): Promise<AdminUserDto[]> => {
const res = await apiClient.get<AdminUserDto[]>("/auth/tokens", {
headers: { Authorization: getAuthHeader() },
});
return res.data;
},
createUser: async (payload: CreateUserPayload): Promise<void> => {
await apiClient.post("/auth/token", payload, {
headers: { Authorization: getAuthHeader() },
});
},
deleteUser: async (login: string): Promise<void> => {
await apiClient.delete(`/auth/tokens/${login}`, {
headers: { Authorization: getAuthHeader() },
});
},
activateUser: async (login: string): Promise<void> => {
await apiClient.post(
`/auth/users/${login}/activate`,
{},
{ headers: { Authorization: getAuthHeader() } },
);
},
deactivateUser: async (login: string): Promise<void> => {
await apiClient.post(
`/auth/users/${login}/deactivate`,
{},
{ headers: { Authorization: getAuthHeader() } },
);
},
updatePermissions: async (
login: string,
payload: PermissionsPayload,
): Promise<void> => {
await apiClient.put(`/auth/users/${login}/permissions`, payload, {
headers: { Authorization: getAuthHeader() },
});
},
generateToken: async (label: string): Promise<string> => {
const res = await apiClient.post<{ token: string }>(
"/agents/register-token",
{ label },
{ headers: { Authorization: getAuthHeader() } },
);
return res.data.token;
},
};
@@ -0,0 +1,310 @@
import React, { useState } from "react";
import { FaTimes, FaPlus } from "react-icons/fa";
import { useAdminStore } from "../store/useAdminStore";
interface CreateUserModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
isOpen,
onClose,
}) => {
const createUser = useAdminStore((s) => s.createUser);
const [form, setForm] = useState({
login: "",
name: "",
last_name: "",
password: "",
is_active: true,
permission_admin: false,
permission_manage_agent: false,
permission_view: true,
});
const [loading, setLoading] = useState(false);
if (!isOpen) return null;
const handleSubmit = async () => {
if (!form.login || !form.password) return;
setLoading(true);
await createUser(form);
setLoading(false);
setForm({
login: "",
name: "",
last_name: "",
password: "",
is_active: true,
permission_admin: false,
permission_manage_agent: false,
permission_view: true,
});
onClose();
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: "var(--card-bg)",
borderRadius: "8px",
padding: "24px",
minWidth: "380px",
border: "1px solid var(--border)",
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "20px",
}}
>
<h3
style={{
margin: 0,
fontSize: "16px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Создать пользователя
</h3>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "var(--text-secondary)",
cursor: "pointer",
padding: "4px",
}}
>
<FaTimes size={14} />
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Login */}
<div>
<label
style={{
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
display: "block",
}}
>
Логин
</label>
<input
type="text"
value={form.login}
onChange={(e) => setForm({ ...form, login: e.target.value })}
style={{
width: "100%",
padding: "8px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
}}
/>
</div>
{/* Password */}
<div>
<label
style={{
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
display: "block",
}}
>
Пароль
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
style={{
width: "100%",
padding: "8px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
}}
/>
</div>
{/* Name + Last name */}
<div style={{ display: "flex", gap: "8px" }}>
<div style={{ flex: 1 }}>
<label
style={{
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
display: "block",
}}
>
Имя
</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
style={{
width: "100%",
padding: "8px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
}}
/>
</div>
<div style={{ flex: 1 }}>
<label
style={{
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
display: "block",
}}
>
Фамилия
</label>
<input
type="text"
value={form.last_name}
onChange={(e) =>
setForm({ ...form, last_name: e.target.value })
}
style={{
width: "100%",
padding: "8px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
}}
/>
</div>
</div>
{/* Permissions */}
<div style={{ paddingTop: "8px" }}>
<label
style={{
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "8px",
display: "block",
}}
>
Разрешения
</label>
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
{[
{ key: "is_active", label: "Active" },
{ key: "permission_view", label: "View" },
{ key: "permission_manage_agent", label: "Manage Agent" },
{ key: "permission_admin", label: "Admin" },
].map(({ key, label }) => (
<label
key={key}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "12px",
color: "var(--text-secondary)",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={
form[key as keyof typeof form] as boolean
}
onChange={(e) =>
setForm({ ...form, [key]: e.target.checked })
}
style={{ accentColor: "var(--accent)" }}
/>
{label}
</label>
))}
</div>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={loading || !form.login || !form.password}
style={{
marginTop: "8px",
padding: "10px",
backgroundColor:
loading || !form.login || !form.password
? "var(--bg-secondary)"
: "var(--accent)",
color:
loading || !form.login || !form.password
? "var(--text-muted)"
: "var(--accent-text)",
border: "none",
borderRadius: "6px",
cursor:
loading || !form.login || !form.password
? "default"
: "pointer",
fontSize: "13px",
fontWeight: 500,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "6px",
}}
>
<FaPlus size={12} />
{loading ? "Создание..." : "Создать"}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,207 @@
import React from "react";
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
import type { AdminUser, PermissionKey } from "../types";
import { useAdminStore } from "../store/useAdminStore";
interface UserCardProps {
user: AdminUser;
}
const permissions: { key: PermissionKey; label: string }[] = [
{ key: "permission_view", label: "View" },
{ key: "permission_manage_agent", label: "Manage Agent" },
{ key: "permission_admin", label: "Admin" },
];
export const UserCard: React.FC<UserCardProps> = ({ user }) => {
const users = useAdminStore((s) => s.users);
const toggleActive = useAdminStore((s) => s.toggleActive);
const togglePermission = useAdminStore((s) => s.togglePermission);
const deleteUser = useAdminStore((s) => s.deleteUser);
return (
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
transition: "all 0.2s",
opacity: user.is_active ? 1 : 0.6,
}}
>
{/* Header: User info + Active toggle + Delete */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "16px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
backgroundColor: user.is_active
? "var(--accent)"
: "var(--text-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FaUser size={16} style={{ color: "var(--card-bg)" }} />
</div>
<div>
<div
style={{
fontSize: "14px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{user.name} {user.last_name}
</div>
<div
style={{
fontSize: "12px",
color: "var(--text-secondary)",
}}
>
{user.login}
</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
{/* Active toggle */}
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
style={{
fontSize: "11px",
color: user.is_active
? "var(--success-text, #22c55e)"
: "var(--error-text, #ef4444)",
}}
>
{user.is_active ? "Active" : "Inactive"}
</span>
<button
onClick={() => toggleActive(user.id, user.login, user.is_active)}
style={{
width: "40px",
height: "22px",
borderRadius: "11px",
border: "none",
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
cursor: "pointer",
position: "relative",
transition: "background-color 0.2s",
}}
>
<div
style={{
width: "16px",
height: "16px",
borderRadius: "50%",
backgroundColor: "#fff",
position: "absolute",
top: "3px",
left: user.is_active ? "21px" : "3px",
transition: "left 0.2s",
}}
/>
</button>
</div>
{/* Delete button */}
<button
onClick={() => {
if (window.confirm(`Удалить пользователя "${user.login}"?`)) {
deleteUser(user.id, user.login);
}
}}
title="Удалить"
style={{
background: "transparent",
border: "1px solid transparent",
color: "var(--text-muted)",
cursor: "pointer",
padding: "6px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--error-text, #ef4444)";
e.currentTarget.style.backgroundColor = "rgba(239,68,68,0.1)";
e.currentTarget.style.borderColor = "rgba(239,68,68,0.3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.borderColor = "transparent";
}}
>
<FaTrash size={13} />
</button>
</div>
</div>
{/* Permissions */}
<div
style={{
display: "flex",
gap: "16px",
paddingTop: "12px",
borderTop: "1px solid var(--border)",
}}
>
{permissions.map(({ key, label }) => (
<label
key={key}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "12px",
color: "var(--text-secondary)",
userSelect: "none",
}}
>
<div
onClick={() => togglePermission(user.id, user.login, key, users)}
style={{
width: "18px",
height: "18px",
borderRadius: "4px",
border: "1px solid",
borderColor: user[key] ? "var(--accent)" : "var(--border)",
backgroundColor: user[key] ? "var(--accent)" : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.15s",
cursor: "pointer",
}}
>
{user[key] && (
<FaCheck
size={10}
style={{ color: "var(--accent-text, #fff)" }}
/>
)}
</div>
{label}
</label>
))}
</div>
</div>
);
};
+4
View File
@@ -0,0 +1,4 @@
export { AdminPanel } from "./AdminPanel";
export { useAdminStore } from "./store/useAdminStore";
export { adminApi } from "./api/admin.api";
export type { AdminUser } from "./types";
@@ -0,0 +1,129 @@
import { create } from "zustand";
import type { AdminUser, PermissionKey } from "../types";
import { adminApi } from "../api/admin.api";
import type { CreateUserPayload } from "../api/admin.api";
interface AdminState {
users: AdminUser[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
createUser: (payload: CreateUserPayload) => Promise<void>;
deleteUser: (id: string, login: string) => Promise<void>;
toggleActive: (id: string, login: string, current: boolean) => Promise<void>;
togglePermission: (
id: string,
login: string,
permission: PermissionKey,
users: AdminUser[],
) => Promise<void>;
}
export const useAdminStore = create<AdminState>((set, get) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const data = await adminApi.getUsers();
set({
users: data.map((u) => ({
id: String(u.id),
login: u.login,
name: u.name,
last_name: u.last_name,
is_active: u.is_active,
permission_admin: u.permission_admin,
permission_manage_agent: u.permission_manage_agent,
permission_view: u.permission_view,
})),
loading: false,
});
} catch (e) {
set({
error: e instanceof Error ? e.message : "Failed to fetch users",
loading: false,
});
}
},
createUser: async (payload) => {
try {
await adminApi.createUser(payload);
await get().fetchUsers();
} catch (e) {
set({ error: e instanceof Error ? e.message : "Failed to create user" });
}
},
deleteUser: async (id, login) => {
try {
await adminApi.deleteUser(login);
set((state) => ({
users: state.users.filter((u) => u.id !== id),
}));
} catch (e) {
set({ error: e instanceof Error ? e.message : "Failed to delete user" });
}
},
toggleActive: async (id, login, current) => {
try {
if (current) {
await adminApi.deactivateUser(login);
} else {
await adminApi.activateUser(login);
}
set((state) => ({
users: state.users.map((u) =>
u.id === id ? { ...u, is_active: !current } : u,
),
}));
} catch (e) {
set({
error: e instanceof Error ? e.message : "Failed to toggle active",
});
}
},
togglePermission: async (id, login, permission, users) => {
const user = users.find((u) => u.id === id);
if (!user) return;
const newPermissions = {
is_active: user.is_active,
permission_admin:
permission === "permission_admin"
? !user.permission_admin
: user.permission_admin,
permission_manage_agent:
permission === "permission_manage_agent"
? !user.permission_manage_agent
: user.permission_manage_agent,
permission_view:
permission === "permission_view"
? !user.permission_view
: user.permission_view,
};
try {
await adminApi.updatePermissions(login, newPermissions);
set((state) => ({
users: state.users.map((u) =>
u.id === id
? {
...u,
[permission]: !u[permission],
}
: u,
),
}));
} catch (e) {
set({
error: e instanceof Error ? e.message : "Failed to update permissions",
});
}
},
}));
+15
View File
@@ -0,0 +1,15 @@
export interface AdminUser {
id: string;
login: string;
name: string;
last_name: string;
is_active: boolean;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
}
export type PermissionKey =
| "permission_admin"
| "permission_manage_agent"
| "permission_view";
@@ -0,0 +1,181 @@
import { apiClient } from "@/shared/api/axios.instance";
import type {
AgentInfo,
TokenCreate,
TokenUser,
LogEntry,
LogFilters,
InsertLogRequest,
InsertLogsRequest,
TokenUpdate,
TokenUpdatePermissions,
TokenPasswordReset,
RegistrationRequest,
DeployAgentsRequest,
DeployResponse,
SystemMetrics,
} from "../types/agent.types";
import type { GraphApiResponse } from "@/modules/graph/types";
class AgentApiService {
private readonly basePath = "/agents";
private readonly authBasePath = "/auth";
private readonly logsBasePath = "/logs";
async getAgents(): Promise<AgentInfo[]> {
const response = await apiClient.get<AgentInfo[]>(this.basePath);
return Array.isArray(response.data) ? response.data : [];
}
async getUsers(): Promise<TokenUser[]> {
const response = await apiClient.get<TokenUser[]>(
`${this.authBasePath}/tokens`,
);
return Array.isArray(response.data) ? response.data : [];
}
async createUser(data: TokenCreate): Promise<void> {
await apiClient.post(`${this.authBasePath}/token`, data);
}
async deleteUser(login: string): Promise<void> {
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
}
async deleteMyAccount(): Promise<void> {
await apiClient.delete(`${this.authBasePath}/token`);
}
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
params: {
level: filters?.level || undefined,
service: filters?.service || undefined,
agent: filters?.agent || undefined,
date_from: filters?.date_from || undefined,
date_to: filters?.date_to || undefined,
limit: filters?.limit ?? 100,
offset: filters?.offset ?? 0,
},
});
if (!Array.isArray(response.data)) {
console.error(
"[Logs] Unexpected response format:",
typeof response.data,
response.data,
);
return [];
}
return response.data;
}
async insertLog(entry: InsertLogRequest): Promise<void> {
await apiClient.post(this.logsBasePath, entry);
}
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
await apiClient.post(`${this.logsBasePath}/batch`, data);
}
async getDistinctAgents(): Promise<string[]> {
const response = await apiClient.get<string[]>(
`${this.logsBasePath}/agents`,
);
return Array.isArray(response.data) ? response.data : [];
}
async getDistinctLevels(): Promise<string[]> {
const response = await apiClient.get<string[]>(
`${this.logsBasePath}/levels`,
);
return Array.isArray(response.data) ? response.data : [];
}
async getDistinctServices(): Promise<string[]> {
const response = await apiClient.get<string[]>(
`${this.logsBasePath}/services`,
);
return Array.isArray(response.data) ? response.data : [];
}
// User management methods
async getUserByLogin(login: string): Promise<TokenUser> {
const response = await apiClient.get<TokenUser>(
`${this.authBasePath}/users/${login}`,
);
if (!response.data || typeof response.data !== "object") {
throw new Error(`User not found: ${login}`);
}
return response.data;
}
async getInactiveUsers(): Promise<TokenUser[]> {
const response = await apiClient.get<TokenUser[]>(
`${this.authBasePath}/users/inactive`,
);
return Array.isArray(response.data) ? response.data : [];
}
async updateUser(login: string, data: TokenUpdate): Promise<void> {
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
}
async updateUserPermissions(
login: string,
data: TokenUpdatePermissions,
): Promise<void> {
await apiClient.put(
`${this.authBasePath}/users/${login}/permissions`,
data,
);
}
async resetUserPassword(
login: string,
data: TokenPasswordReset,
): Promise<void> {
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
}
async activateUser(login: string): Promise<void> {
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
}
async deactivateUser(login: string): Promise<void> {
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
}
async createRegistrationToken(
data: RegistrationRequest,
): Promise<Record<string, string>> {
const response = await apiClient.post<Record<string, string>>(
`${this.basePath}/register-token`,
data,
);
return response.data;
}
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
const response = await apiClient.post<DeployResponse>(
`${this.basePath}/deploy`,
data,
);
return response.data;
}
async getSystemMetrics(): Promise<SystemMetrics[]> {
const response = await apiClient.get<SystemMetrics[]>(
`${this.basePath}/system-metrics`,
);
return Array.isArray(response.data) ? response.data : [];
}
async getGraph(): Promise<GraphApiResponse> {
const response = await apiClient.get<GraphApiResponse>("/graph");
return response.data;
}
}
export const agentApiService = new AgentApiService();
@@ -0,0 +1,36 @@
import { useState, useEffect, useCallback } from "react";
import { agentApiService } from "../api/agent.api.service";
import type { AgentInfo } from "../types/agent.types";
interface UseAgentsResult {
agents: AgentInfo[];
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useAgents(): UseAgentsResult {
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAgents = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await agentApiService.getAgents();
setAgents(data);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch agents";
setError(message);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
return { agents, isLoading, error, refetch: fetchAgents };
}
+26
View File
@@ -0,0 +1,26 @@
export { SSHAgentForm } from "./ui/SSHAgentForm";
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
export { useAgents } from "./hooks/useAgents.hook";
export { agentApiService } from "./api/agent.api.service";
export type {
AgentInfo,
LoginRequest,
LoginResponse,
TokenCreate,
TokenUser,
LogEntry,
InsertLogRequest,
InsertLogsRequest,
LogFilters,
TokenUpdate,
TokenUpdatePermissions,
TokenPasswordReset,
RegistrationRequest,
DeployResult,
DeployAgentsRequest,
AgentDeployConfig,
DeployResponse,
} from "./types/agent.types";
@@ -0,0 +1,87 @@
import { create } from "zustand";
export type LogLevel = "info" | "warning" | "error" | "fatal";
interface LogFilterState {
searchQuery: string;
startDate: Date | null;
endDate: Date | null;
selectedLogLevel: LogLevel | null;
selectedService: string;
selectedAgent: string;
limit: number;
offset: number;
setSearchQuery: (query: string) => void;
setStartDate: (date: Date | null) => void;
setEndDate: (date: Date | null) => void;
setSelectedLogLevel: (level: LogLevel | null) => void;
setSelectedService: (service: string) => void;
setSelectedAgent: (agent: string) => void;
setLimit: (limit: number) => void;
setOffset: (offset: number) => void;
resetFilters: () => void;
getFilters: () => {
level?: string;
service?: string;
agent?: string;
date_from?: string;
date_to?: string;
limit: number;
offset: number;
};
}
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
searchQuery: "",
startDate: null,
endDate: null,
selectedLogLevel: null,
selectedService: "",
selectedAgent: "",
limit: 100,
offset: 0,
setSearchQuery: (query) => set({ searchQuery: query }),
setStartDate: (date) => set({ startDate: date }),
setEndDate: (date) => set({ endDate: date }),
setSelectedLogLevel: (level) => set({ selectedLogLevel: level }),
setSelectedService: (service) => set({ selectedService: service }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
setLimit: (limit) => set({ limit }),
setOffset: (offset) => set({ offset }),
resetFilters: () => {
set({
searchQuery: "",
startDate: null,
endDate: null,
selectedLogLevel: null,
selectedService: "",
selectedAgent: "",
limit: 100,
offset: 0,
});
},
getFilters: () => {
const {
selectedLogLevel,
selectedService,
selectedAgent,
startDate,
endDate,
limit,
offset,
} = get();
return {
level: selectedLogLevel || undefined,
service: selectedService || undefined,
agent: selectedAgent || undefined,
date_from: startDate ? startDate.toISOString() : undefined,
date_to: endDate ? endDate.toISOString() : undefined,
limit,
offset,
};
},
}));
@@ -0,0 +1,131 @@
export interface AgentInfo {
token: string;
label: string;
services: string[];
connected_at: string;
}
export interface LoginRequest {
login: string;
password: string;
}
export interface LoginResponse {
last_name: string;
login: string;
name: string;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
token: string;
}
export interface TokenCreate {
login: string;
name: string;
last_name: string;
password: string;
permission_admin?: boolean;
permission_manage_agent?: boolean;
permission_view?: boolean;
}
export interface TokenUser {
id: number;
login: string;
name: string;
last_name: string;
permission_admin: boolean;
permission_manage_agent: boolean;
permission_view: boolean;
token: string;
}
export interface LogEntry {
Agent: string;
Level: string;
Message: string;
Service: string;
Timestamp: string;
}
export interface InsertLogRequest {
agent: string;
level: string;
message: string;
service: string;
timestamp?: string;
}
export interface InsertLogsRequest {
logs: InsertLogRequest[];
}
export interface LogFilters {
level?: string | string[];
service?: string;
agent?: string;
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
}
export interface TokenUpdate {
name?: string;
last_name?: string;
}
export interface TokenUpdatePermissions {
is_active?: boolean;
permission_admin?: boolean;
permission_manage_agent?: boolean;
permission_view?: boolean;
}
export interface TokenPasswordReset {
new_password: string;
}
export interface RegistrationRequest {
label: string;
}
export interface DeployResult {
agent_label: string;
error?: string;
ip: string;
success: boolean;
token?: string;
}
export interface DeployAgentsRequest {
servers: AgentDeployConfig[];
}
export interface AgentDeployConfig {
agentLabel: string;
authMethod: "key" | "password";
deployType: "docker" | "binary";
ip: string;
password?: string;
port?: number;
sshKey?: string;
user: string;
}
export interface DeployResponse {
message?: string;
results: DeployResult[];
}
export interface SystemMetrics {
connected_at: string;
cpu_percent: number;
disk_percent: number;
id: string;
label: string;
memory_percent: number;
network_rx_bytes: number;
network_tx_bytes: number;
}
@@ -0,0 +1,556 @@
import React, { useState, useEffect, useCallback } from "react";
import {
FiSearch,
FiX,
FiFilter,
FiCalendar,
FiTag,
FiCheck,
} from "react-icons/fi";
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
const logLevelColors: Record<
LogLevel,
{ bg: string; text: string; border: string }
> = {
info: {
bg: "rgba(59, 130, 246, 0.1)",
text: "#3b82f6",
border: "rgba(59, 130, 246, 0.3)",
},
warning: {
bg: "rgba(245, 158, 11, 0.1)",
text: "#f59e0b",
border: "rgba(245, 158, 11, 0.3)",
},
error: {
bg: "var(--error-bg)",
text: "var(--error-text)",
border: "var(--error-border)",
},
fatal: {
bg: "rgba(168, 85, 247, 0.1)",
text: "#a855f7",
border: "rgba(168, 85, 247, 0.3)",
},
};
interface LogFiltersProps {
onApply: () => void;
availableServices: string[];
availableAgents: string[];
}
export const LogFilters: React.FC<LogFiltersProps> = ({
onApply,
availableServices,
availableAgents,
}) => {
const {
searchQuery,
startDate,
endDate,
selectedLogLevel,
selectedService,
selectedAgent,
setSearchQuery,
setStartDate,
setEndDate,
setSelectedLogLevel,
setSelectedService,
setSelectedAgent,
resetFilters,
} = useLogFilterStore();
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
const [localService, setLocalService] = useState(selectedService);
const [localAgent, setLocalAgent] = useState(selectedAgent);
const [localLevel, setLocalLevel] = useState<LogLevel | null>(
selectedLogLevel,
);
useEffect(() => {
setLocalSearchQuery(searchQuery);
}, [searchQuery]);
useEffect(() => {
setLocalStartDate(startDate);
}, [startDate]);
useEffect(() => {
setLocalEndDate(endDate);
}, [endDate]);
useEffect(() => {
setLocalService(selectedService);
}, [selectedService]);
useEffect(() => {
setLocalAgent(selectedAgent);
}, [selectedAgent]);
useEffect(() => {
setLocalLevel(selectedLogLevel);
}, [selectedLogLevel]);
const handleApply = useCallback(() => {
setSearchQuery(localSearchQuery);
setStartDate(localStartDate);
setEndDate(localEndDate);
setSelectedLogLevel(localLevel);
setSelectedService(localService);
setSelectedAgent(localAgent);
onApply();
}, [
localSearchQuery,
localStartDate,
localEndDate,
localLevel,
localService,
localAgent,
onApply,
]);
const handleReset = useCallback(() => {
setLocalSearchQuery("");
setLocalStartDate(null);
setLocalEndDate(null);
setLocalLevel(null);
setLocalService("");
setLocalAgent("");
resetFilters();
onApply();
}, [resetFilters, onApply]);
const getActiveFiltersCount = () => {
let count = 0;
if (searchQuery) count++;
if (startDate) count++;
if (endDate) count++;
if (selectedService) count++;
if (selectedAgent) count++;
if (selectedLogLevel) count++;
return count;
};
const formatDate = (date: Date | null) => {
if (!date) return null;
return date.toLocaleDateString("ru-RU");
};
const activeFiltersCount = getActiveFiltersCount();
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "6px",
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
fontSize: "13px",
};
const selectStyle: React.CSSProperties = {
...inputStyle,
cursor: "pointer",
};
return (
<div
className="rounded-xl border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FiFilter size={14} style={{ color: "var(--accent)" }} />
<h3
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Фильтры логов
</h3>
</div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
Активно: {activeFiltersCount}
</span>
</div>
{/* Filters Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
{/* Search */}
<div className="relative">
<FiSearch
style={{
position: "absolute",
left: "10px",
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-muted)",
fontSize: "14px",
}}
/>
<input
type="text"
value={localSearchQuery}
onChange={(e) => setLocalSearchQuery(e.target.value)}
placeholder="Поиск по сообщению..."
style={{ ...inputStyle, paddingLeft: "32px" }}
onKeyDown={(e) => e.key === "Enter" && handleApply()}
/>
</div>
{/* Service Select */}
<select
value={localService}
onChange={(e) => setLocalService(e.target.value)}
style={selectStyle}
>
<option value="">Все сервисы</option>
{availableServices.map((service) => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
{/* Agent Select */}
<select
value={localAgent}
onChange={(e) => setLocalAgent(e.target.value)}
style={selectStyle}
>
<option value="">Все агенты</option>
{availableAgents.map((agent) => (
<option key={agent} value={agent}>
{agent}
</option>
))}
</select>
{/* Date Range */}
<div className="flex gap-2">
<input
type="date"
value={
localStartDate ? localStartDate.toISOString().split("T")[0] : ""
}
onChange={(e) =>
setLocalStartDate(
e.target.value ? new Date(e.target.value) : null,
)
}
style={{ ...inputStyle, minWidth: 0 }}
placeholder="Дата от"
className="flex-1 min-w-0"
/>
<input
type="date"
value={
localEndDate ? localEndDate.toISOString().split("T")[0] : ""
}
onChange={(e) =>
setLocalEndDate(
e.target.value ? new Date(e.target.value) : null,
)
}
style={{ ...inputStyle, minWidth: 0 }}
placeholder="Дата до"
className="flex-1 min-w-0"
/>
</div>
</div>
{/* Log Levels */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
<span
className="text-xs font-medium"
style={{ color: "var(--text-secondary)" }}
>
Уровень логов
</span>
</div>
<div className="flex flex-wrap gap-2">
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
(level) => {
const isSelected = localLevel === level;
const colors = logLevelColors[level];
return (
<button
key={level}
onClick={() => setLocalLevel(isSelected ? null : level)}
className="px-3 py-2 rounded-lg text-xs font-medium transition-all border flex-shrink-0"
style={{
backgroundColor: isSelected ? colors.bg : "transparent",
color: isSelected ? colors.text : "var(--text-secondary)",
borderColor: isSelected ? colors.border : "var(--border)",
minHeight: "36px",
}}
onMouseEnter={(e) => {
if (isSelected) {
e.currentTarget.style.backgroundColor = colors.text;
e.currentTarget.style.color = "#fff";
} else {
e.currentTarget.style.backgroundColor =
"rgba(128, 128, 128, 0.08)";
e.currentTarget.style.color = "var(--text-primary)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isSelected
? colors.bg
: "transparent";
e.currentTarget.style.color = isSelected
? colors.text
: "var(--text-secondary)";
}}
>
{isSelected && (
<FiCheck size={10} className="inline mr-1" />
)}
{level.toUpperCase()}
</button>
);
},
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={handleApply}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
minHeight: "44px",
}}
>
<FiCheck size={14} />
Применить
</button>
<button
onClick={handleReset}
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium border"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
borderColor: "var(--border)",
minHeight: "44px",
}}
>
<FiX size={14} />
Сбросить
</button>
</div>
{/* Active Filters Display */}
{activeFiltersCount > 0 && (
<div
className="mt-4 pt-4 border-t"
style={{ borderColor: "var(--border)" }}
>
<div className="flex items-center gap-2 mb-2">
<FiFilter size={10} style={{ color: "var(--accent)" }} />
<span
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
Активные фильтры:
</span>
</div>
<div className="flex flex-wrap gap-2">
{searchQuery && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiSearch size={10} />
<span style={{ color: "var(--text-primary)" }}>
Поиск: {searchQuery}
</span>
<button
onClick={() => {
setLocalSearchQuery("");
setSearchQuery("");
onApply();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
}}
>
<FiX size={10} />
</button>
</div>
)}
{selectedService && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiTag size={10} />
<span style={{ color: "var(--text-primary)" }}>
Сервис: {selectedService}
</span>
<button
onClick={() => {
setLocalService("");
setSelectedService("");
onApply();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
}}
>
<FiX size={10} />
</button>
</div>
)}
{selectedLogLevel &&
(() => {
const colors = logLevelColors[selectedLogLevel];
return (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: colors.bg,
borderColor: colors.border,
}}
>
<FiTag size={10} style={{ color: colors.text }} />
<span style={{ color: colors.text }}>
Уровень: {selectedLogLevel.toUpperCase()}
</span>
<button
onClick={() => {
setLocalLevel(null);
setSelectedLogLevel(null);
onApply();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: colors.text,
}}
>
<FiX size={10} />
</button>
</div>
);
})()}
{selectedAgent && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiTag size={10} />
<span style={{ color: "var(--text-primary)" }}>
Агент: {selectedAgent}
</span>
<button
onClick={() => {
setLocalAgent("");
setSelectedAgent("");
onApply();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
}}
>
<FiX size={10} />
</button>
</div>
)}
{startDate && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiCalendar size={10} />
<span style={{ color: "var(--text-primary)" }}>
С: {formatDate(startDate)}
</span>
<button
onClick={() => {
setLocalStartDate(null);
setStartDate(null);
onApply();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
}}
>
<FiX size={10} />
</button>
</div>
)}
{endDate && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiCalendar size={10} />
<span style={{ color: "var(--text-primary)" }}>
По: {formatDate(endDate)}
</span>
<button
onClick={() => {
setLocalEndDate(null);
setEndDate(null);
onApply();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
}}
>
<FiX size={10} />
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
};
+51 -3
View File
@@ -7,6 +7,7 @@ import {
FiPlus,
FiTrash2,
FiSettings,
FiLink,
} from "react-icons/fi";
import { SiDocker } from "react-icons/si";
import { FiPackage, FiUploadCloud } from "react-icons/fi";
@@ -20,8 +21,10 @@ interface ExtraField {
}
export interface SSHAgentConfig {
agentLabel: string;
user: string;
ip: string;
port: number;
authMethod: AuthMethod;
sshKey?: string;
password?: string;
@@ -189,11 +192,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
</div>
<div style={{ display: "grid", gap: "20px" }}>
{/* User и IP */}
{/* Agent Label */}
<div>
<label style={labelStyle}>
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<FiServer size={14} />
Метка агента *
</span>
</label>
<input
type="text"
value={config.agentLabel}
onChange={(e) => handleChange("agentLabel", e.target.value)}
required
style={inputBaseStyle}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="production-server-1"
/>
</div>
{/* User, IP и Port */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridTemplateColumns: "1fr 1fr 1fr",
gap: "16px",
}}
>
@@ -238,6 +261,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
placeholder="192.168.1.1"
/>
</div>
<div>
<label style={labelStyle}>
<span
style={{ display: "flex", alignItems: "center", gap: "6px" }}
>
<FiLink size={14} />
Порт *
</span>
</label>
<input
type="number"
value={config.port}
onChange={(e) =>
handleChange("port", parseInt(e.target.value) || 22)
}
required
min={1}
max={65535}
style={inputBaseStyle}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="22"
/>
</div>
</div>
{/* Метод аутентификации */}
@@ -457,7 +505,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateColumns: "1fr 1fr",
gap: "8px",
}}
>
+16 -10
View File
@@ -17,13 +17,18 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
return response.data;
};
const register = async (data: RegisterData): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>("/auth/register", {
login: data.login,
password: data.password,
name: data.firstName,
last_name: data.lastName,
});
const register = async (
data: RegisterData,
): Promise<Record<string, string>> => {
const response = await apiClient.post<Record<string, string>>(
"/auth/register",
{
login: data.login,
password: data.password,
name: data.firstName,
last_name: data.lastName,
},
);
return response.data;
};
@@ -62,9 +67,10 @@ export const useAuthStore = create<AuthState>()(
register: async (data: RegisterData) => {
set({ isLoading: true, error: null });
try {
const response = await register(data);
const user = mapResponseToUser(response);
set({ user, token: response.token, isLoading: false });
await register(data);
// После регистрации пользователь не авторизуется автоматически
// Нужно войти через /auth/login
set({ isLoading: false });
} catch (error) {
set({
error:
@@ -0,0 +1,20 @@
import React from "react";
import { FaPlus } from "react-icons/fa";
interface AddWidgetButtonProps {
onClick: () => void;
}
export const AddWidgetButton: React.FC<AddWidgetButtonProps> = ({
onClick,
}) => {
return (
<button
onClick={onClick}
className="w-full py-1.5 bg-tertiary hover:bg-tertiary/70 rounded-lg border border-primary transition-colors flex items-center justify-center gap-1 cursor-pointer"
>
<FaPlus size={10} className="text-tertiary" />
<span className="text-[10px] text-secondary">Добавить график</span>
</button>
);
};
@@ -0,0 +1,108 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { ChartType } from "../types";
interface AddWidgetModalProps {
isOpen: boolean;
onAdd: (data: { type: ChartType; title: string; dataKey: string }) => void;
onClose: () => void;
}
export const AddWidgetModal: React.FC<AddWidgetModalProps> = ({
isOpen,
onAdd,
onClose,
}) => {
const [type, setType] = useState<ChartType>("line");
const [title, setTitle] = useState("");
const [dataKey, setDataKey] = useState("requests");
const handleAdd = () => {
if (!title.trim()) return;
onAdd({ type, title: title.trim(), dataKey });
setTitle("");
setType("line");
setDataKey("requests");
onClose();
};
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xs font-semibold text-primary mb-3">
Добавить график
</h3>
<div className="space-y-2">
<div>
<label className="block text-[10px] text-secondary mb-1">
Тип
</label>
<div className="flex gap-1">
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
<button
key={t}
onClick={() => setType(t)}
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
type === t
? "bg-accent-primary text-white"
: "bg-tertiary text-secondary hover:bg-tertiary/70"
}`}
>
{t === "line" && "📈"}
{t === "bar" && "📊"}
{t === "area" && "📉"}
{t === "pie" && "🥧"}
</button>
))}
</div>
</div>
<div>
<label className="block text-[10px] text-secondary mb-1">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Название"
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
/>
</div>
<div className="flex gap-1 pt-2">
<button
onClick={handleAdd}
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
>
Добавить
</button>
<button
onClick={onClose}
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
>
Отмена
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,299 @@
// modules/dashboard/components/ChartWidget.tsx
import React from "react";
import {
LineChart,
Line,
BarChart,
Bar,
AreaChart,
Area,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import {
FaChartLine,
FaChartBar,
FaChartArea,
FaChartPie,
FaCog,
FaEye,
FaEyeSlash,
} from "react-icons/fa";
import { motion } from "framer-motion";
import type { ChartWidget as ChartWidgetType, MetricData } from "../types";
interface ChartWidgetProps {
widget: ChartWidgetType;
data: MetricData[];
onEdit: () => void;
onToggleVisibility: () => void;
}
// Все возможные уровни логов (метрики)
const METRICS = ["INFO", "WARN", "ERROR", "DEBUG"];
// Цвета для каждой метрики
const METRIC_COLORS: Record<string, string> = {
INFO: "#10b981", // зеленый
WARN: "#f59e0b", // оранжевый
ERROR: "#ef4444", // красный
DEBUG: "#3b82f6", // синий
};
export const ChartWidget: React.FC<ChartWidgetProps> = ({
widget,
data,
onEdit,
onToggleVisibility,
}) => {
const renderChart = () => {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-[10px] text-tertiary">Нет данных</span>
</div>
);
}
const normalizedData = data.map((point) => {
const normalized: MetricData = { timestamp: point.timestamp };
METRICS.forEach((metric) => {
normalized[metric] = point[metric] || 0;
});
return normalized;
});
const commonProps = {
data: normalizedData,
margin: { top: 5, right: 10, left: 0, bottom: 5 },
};
switch (widget.type) {
case "line":
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
stroke="#64748b"
tick={{ fontSize: 9 }}
interval={Math.floor(normalizedData.length / 5)}
/>
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
verticalAlign="top"
height={25}
/>
{METRICS.map((metric) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={METRIC_COLORS[metric]}
strokeWidth={2}
dot={false}
name={metric}
/>
))}
</LineChart>
);
case "bar":
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
stroke="#64748b"
tick={{ fontSize: 9 }}
interval={Math.floor(normalizedData.length / 5)}
/>
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
verticalAlign="top"
height={25}
/>
{METRICS.map((metric) => (
<Bar
key={metric}
dataKey={metric}
fill={METRIC_COLORS[metric]}
radius={[2, 2, 0, 0]}
name={metric}
/>
))}
</BarChart>
);
case "area":
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="timestamp"
stroke="#64748b"
tick={{ fontSize: 9 }}
interval={Math.floor(normalizedData.length / 5)}
/>
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
verticalAlign="top"
height={25}
/>
{METRICS.map((metric) => (
<Area
key={metric}
type="monotone"
dataKey={metric}
stroke={METRIC_COLORS[metric]}
fill={METRIC_COLORS[metric]}
fillOpacity={0.2}
name={metric}
/>
))}
</AreaChart>
);
case "pie":
// Для круговой диаграммы берем последнюю точку
const lastPoint = normalizedData[normalizedData.length - 1];
const pieData = METRICS.map((metric) => ({
name: metric,
value: lastPoint[metric] || 0,
})).filter((item) => Number(item.value) > 0);
return (
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={55}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={METRIC_COLORS[entry.name]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "10px",
}}
labelStyle={{ color: "#fff" }}
/>
<Legend
wrapperStyle={{ fontSize: "10px" }}
layout="vertical"
verticalAlign="middle"
align="right"
/>
</PieChart>
);
default:
return null;
}
};
const getIcon = () => {
switch (widget.type) {
case "line":
return <FaChartLine size={10} />;
case "bar":
return <FaChartBar size={10} />;
case "area":
return <FaChartArea size={10} />;
case "pie":
return <FaChartPie size={10} />;
default:
return null;
}
};
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={`bg-secondary rounded-lg border border-primary p-2 transition-all ${!widget.visible ? "opacity-50" : ""}`}
>
<div className="flex items-center justify-between mb-1 px-1">
<div className="flex items-center gap-1">
<span className="text-tertiary">{getIcon()}</span>
<h3 className="text-[11px] font-medium text-primary">
{widget.title}
</h3>
</div>
<div className="flex gap-0.5">
<button
onClick={onToggleVisibility}
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
title={widget.visible ? "Скрыть" : "Показать"}
>
{widget.visible ? (
<FaEye size={9} className="text-tertiary" />
) : (
<FaEyeSlash size={9} className="text-tertiary" />
)}
</button>
<button
onClick={onEdit}
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
title="Настройки"
>
<FaCog size={9} className="text-tertiary" />
</button>
</div>
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
</motion.div>
);
};
@@ -0,0 +1,105 @@
// modules/dashboard/components/WidgetSettings.tsx
import React, { useState } from "react";
import { motion } from "framer-motion";
import type { ChartType, ChartWidget } from "../types";
interface WidgetSettingsProps {
widget: ChartWidget;
onUpdate: (widget: ChartWidget) => void;
onRemove: () => void;
onClose: () => void;
}
export const WidgetSettings: React.FC<WidgetSettingsProps> = ({
widget,
onUpdate,
onRemove,
onClose,
}) => {
const [type, setType] = useState<ChartType>(widget.type);
const [title, setTitle] = useState(widget.title);
const handleSave = () => {
onUpdate({ ...widget, type, title });
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xs font-semibold text-primary mb-3">
Настройки графика
</h3>
<div className="space-y-2">
<div>
<label className="block text-[10px] text-secondary mb-1">Тип</label>
<div className="flex gap-1">
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
<button
key={t}
onClick={() => setType(t)}
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
type === t
? "bg-accent-primary text-white"
: "bg-tertiary text-secondary hover:bg-tertiary/70"
}`}
>
{t === "line" && "📈"}
{t === "bar" && "📊"}
{t === "area" && "📉"}
{t === "pie" && "🥧"}
</button>
))}
</div>
</div>
<div>
<label className="block text-[10px] text-secondary mb-1">
Название
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
/>
</div>
<div className="flex gap-1 pt-2">
<button
onClick={handleSave}
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
>
Сохранить
</button>
<button
onClick={onRemove}
className="px-2 py-1 bg-red-500/10 text-red-500 rounded text-[10px] hover:bg-red-500/20 transition-colors cursor-pointer"
>
Удалить
</button>
<button
onClick={onClose}
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
>
Отмена
</button>
</div>
</div>
</motion.div>
</motion.div>
);
};
@@ -0,0 +1,171 @@
import React from "react";
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { motion } from "framer-motion";
import type { ChartType, MetricData } from "../types";
interface DashboardChartProps {
title: string;
type: ChartType;
data: MetricData[];
dataKeys: string[];
colors?: string[];
}
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
export const DashboardChart: React.FC<DashboardChartProps> = ({
title,
type,
data,
dataKeys,
colors = COLORS,
}) => {
const renderChart = () => {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Нет данных
</span>
</div>
);
}
const commonProps = {
data,
margin: { top: 5, right: 10, left: 0, bottom: 5 },
};
const axisStyle = {
stroke: "var(--text-secondary)",
tick: { fontSize: 10 },
};
const tooltipStyle = {
contentStyle: {
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
fontSize: "11px",
},
labelStyle: { color: "var(--text-primary)" },
};
if (type === "pie") {
// Если данные уже в формате { name, value } — используем напрямую
const isPieFormat =
data.length > 0 && "name" in data[0] && "value" in data[0];
const pieData = isPieFormat
? data
: data.map((point, i) => ({
name: dataKeys[i % dataKeys.length],
value: point[dataKeys[i % dataKeys.length]] || 0,
}));
return (
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={60}
paddingAngle={3}
dataKey="value"
nameKey="name"
>
{pieData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
))}
</Pie>
<Tooltip {...tooltipStyle} />
<Legend
wrapperStyle={{ fontSize: "11px" }}
layout="vertical"
verticalAlign="middle"
align="right"
/>
</PieChart>
);
}
const ChartComponent =
type === "line" ? LineChart : type === "area" ? AreaChart : BarChart;
const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar;
return (
<ChartComponent {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
{...axisStyle}
interval={Math.floor(data.length / 5)}
/>
<YAxis {...axisStyle} width={35} />
<Tooltip {...tooltipStyle} />
<Legend wrapperStyle={{ fontSize: "11px" }} />
{dataKeys.map((key, i) => (
<DataComponent
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
fill={colors[i % colors.length]}
fillOpacity={type === "area" ? 0.2 : undefined}
strokeWidth={2}
dot={false}
name={key}
radius={type === "bar" ? [2, 2, 0, 0] : undefined}
/>
))}
</ChartComponent>
);
};
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
style={{
padding: "8px",
}}
>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
{title}
</h3>
<div style={{ height: 180 }}>
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
</motion.div>
);
};
@@ -0,0 +1,96 @@
// modules/dashboard/Dashboard.tsx
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useEffect, useRef, useState } from "react";
import { useDashboardStore } from "./store/dashboard.store";
import { useAuthStore } from "../auth/store/useAuthStore";
import { ChartWidget } from "./components/chart,widget";
import { AddWidgetButton } from "./components/add.widget.button";
import { AddWidgetModal } from "./components/add.widget.modal";
import { WidgetSettings } from "./components/chart.settings";
import { useWidgets } from "./hooks/use.widget";
export const Dashboard: React.FC = () => {
const { chartData, loading, error, fetchMetrics, clearData } =
useDashboardStore();
// const { servicesQueryParams } = useAgentStore();
const intervalRef = useRef<number | null>(null);
const { token } = useAuthStore();
// Первичная загрузка (не latest)
// const fetchPrimaryData = () => {
// fetchMetrics(false, token || "", servicesQueryParams, { since: "10m" });
// };
// Периодическое обновление (latest)
// const fetchLatestData = () => {
// fetchMetrics(true, token || "", servicesQueryParams);
// };
// useEffect(() => {
// fetchPrimaryData();
// }, []);
// useEffect(() => {
// intervalRef.current = window.setInterval(() => {
// fetchLatestData();
// }, 30000);
// return () => {
// if (intervalRef.current) {
// window.clearInterval(intervalRef.current);
// }
// clearData();
// };
// }, [servicesQueryParams]);
const { widgets, addWidget, updateWidget, removeWidget, toggleVisibility } =
useWidgets();
const [editingWidget, setEditingWidget] = useState<any>(null);
const [isAdding, setIsAdding] = useState(false);
const visibleWidgets = widgets.filter((w) => w.visible);
return (
<div className="p-4">
{loading && chartData.length === 0 ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-primary border-t-transparent" />
</div>
) : error ? (
<div className="flex items-center justify-center h-40">
<span className="text-[10px] text-red-500">{error}</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
{visibleWidgets.map((widget) => (
<ChartWidget
key={widget.id}
widget={widget}
data={chartData}
onEdit={() => setEditingWidget(widget)}
onToggleVisibility={() => toggleVisibility(widget.id)}
/>
))}
</div>
)}
<AddWidgetButton onClick={() => setIsAdding(true)} />
<AddWidgetModal
isOpen={isAdding}
onAdd={addWidget}
onClose={() => setIsAdding(false)}
/>
{editingWidget && (
<WidgetSettings
widget={editingWidget}
onUpdate={updateWidget}
onRemove={() => removeWidget(editingWidget.id)}
onClose={() => setEditingWidget(null)}
/>
)}
</div>
);
};
@@ -0,0 +1,72 @@
import { useState } from "react";
import type { ChartType, ChartWidget } from "../types";
const initialWidgets: ChartWidget[] = [
{
id: "1",
type: "line",
title: "Линии",
dataKey: "chart-line",
visible: true,
},
{
id: "2",
type: "bar",
title: "Столбцы",
dataKey: "chart-bar",
visible: true,
},
{
id: "3",
type: "area",
title: "Закрашенные линии",
dataKey: "chart-area",
visible: true,
},
{
id: "4",
type: "pie",
title: "Круговая диаграмма",
dataKey: "chart-pie",
visible: true,
},
];
export const useWidgets = () => {
const [widgets, setWidgets] = useState<ChartWidget[]>(initialWidgets);
const addWidget = (data: {
type: ChartType;
title: string;
dataKey: string;
}) => {
const newWidget: ChartWidget = {
id: Date.now().toString(),
...data,
visible: true,
};
setWidgets([...widgets, newWidget]);
};
const updateWidget = (updated: ChartWidget) => {
setWidgets(widgets.map((w) => (w.id === updated.id ? updated : w)));
};
const removeWidget = (id: string) => {
setWidgets(widgets.filter((w) => w.id !== id));
};
const toggleVisibility = (id: string) => {
setWidgets(
widgets.map((w) => (w.id === id ? { ...w, visible: !w.visible } : w)),
);
};
return {
widgets,
addWidget,
updateWidget,
removeWidget,
toggleVisibility,
};
};
@@ -0,0 +1,129 @@
import { create } from "zustand";
import { apiService } from "@/shared/api/api.service";
import type { MetricData } from "../types";
interface DashboardState {
chartData: MetricData[];
loading: boolean;
error: string | null;
fetchMetrics: (
isLatest: boolean,
token: string,
queryParams?: string,
extraParams?: Record<string, string>,
) => Promise<void>;
clearData: () => void;
}
export const useDashboardStore = create<DashboardState>((set, get) => {
const convertPrimaryData = (response: any) => {
set((state) => {
if (!response.intervals || !Array.isArray(response.intervals))
return { chartData: state.chartData };
const newData = [...state.chartData];
response.intervals.forEach((interval: any) => {
const newPoint: MetricData = {
timestamp: new Date(interval.timestamp).toLocaleTimeString(),
};
if (interval.group_by && Array.isArray(interval.group_by)) {
interval.group_by.forEach((item: any) => {
newPoint[item.value] = item.count;
});
}
newData.push(newPoint);
});
return { chartData: newData.slice(-20) };
});
};
const convertSingleData = (response: any) => {
set((state) => {
const newPoint: MetricData = {
timestamp: new Date().toLocaleTimeString(),
};
if (Array.isArray(response)) {
response.forEach((item: any) => {
newPoint[item.value] = item.count;
});
} else if (response.groupBy && Array.isArray(response.groupBy)) {
response.groupBy.forEach((item: any) => {
newPoint[item.value] = item.count;
});
}
const updatedData = [...state.chartData, newPoint].slice(-20);
return { chartData: updatedData };
});
};
const fetchMetrics = async (
isLatest: boolean,
token: string,
queryParams?: string,
extraParams?: Record<string, string>,
) => {
set({ loading: true, error: null });
try {
let endpoint = isLatest
? "logs/aggregations/latest"
: "logs/aggregations";
// Если есть queryParams, добавляем его к эндпоинту
if (queryParams && queryParams.trim() !== "") {
endpoint = `${endpoint}?${queryParams}`;
}
const params: Record<string, string> = {
agg: "count",
groupby: "level",
...extraParams,
};
const result = await apiService.get<any>(endpoint, {
params,
headers: {
Authorization: `bearer ${token}`,
},
});
if (result) {
if (isLatest) {
convertSingleData(result);
} else {
convertPrimaryData(result);
}
}
} catch (error) {
console.error(
`Failed to fetch ${isLatest ? "latest" : "primary"} metrics:`,
error,
);
set({
error: error instanceof Error ? error.message : "Ошибка запроса",
});
} finally {
set({ loading: false });
}
};
const clearData = () => {
set({ chartData: [], error: null });
};
return {
chartData: [],
loading: false,
error: null,
fetchMetrics,
clearData,
setChartData: (data: MetricData[]) =>
set({ chartData: data, loading: false }),
};
});
+22
View File
@@ -0,0 +1,22 @@
export type ChartType = "line" | "bar" | "area" | "pie";
export interface ChartWidget {
id: string;
type: ChartType;
title: string;
dataKey: string;
visible: boolean;
}
export interface MetricData {
timestamp: string;
[key: string]: number | string;
}
export interface StatsItem {
label: string;
key: string;
icon: string;
color: string;
suffix?: string;
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useRef, useEffect, useState } from "react";
import type {
GraphData,
GraphNode,
GraphLink,
ContextMenuState,
} from "./types";
import { useGraphStore } from "./store/useGraphStore";
import {
ForceGraph,
GraphControls,
GraphContextMenu,
GraphStatusBar,
GraphStats,
} from "./components";
interface GraphProps {
initialData?: GraphData;
onExport?: () => void;
onDataChange?: (data: GraphData) => void;
}
export const Graph: React.FC<GraphProps> = ({
initialData,
onExport,
onDataChange,
}) => {
const fgRef = useRef<any>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const data = useGraphStore((s) => s.data);
const isLinkMode = useGraphStore((s) => s.isLinkMode);
const selectedNode = useGraphStore((s) => s.selectedNode);
const setData = useGraphStore((s) => s.setData);
// Инициализация данных
useEffect(() => {
if (initialData) setData(initialData);
}, [initialData, setData]);
// Закрыть контекстное меню по клику вне
useEffect(() => {
const handleClickOutside = () => setContextMenu(null);
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
const handleNodeRightClick = (node: GraphNode, event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({ x: event.clientX, y: event.clientY, node, link: null });
};
if (!data || data.nodes.length === 0) {
return (
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
</div>
</div>
</div>
);
}
return (
<div
className="p-4 h-full flex flex-col"
style={{ backgroundColor: "var(--card-bg)" }}
>
{/* Статистика сверху */}
<GraphStats data={data} />
{/* Граф */}
<div
className="flex-1 rounded-lg overflow-hidden relative mt-2"
style={{ border: "1px solid var(--border)" }}
>
<ForceGraph
ref={fgRef}
data={data}
onNodeRightClick={handleNodeRightClick}
/>
<GraphContextMenu
menu={contextMenu}
data={data}
onClose={() => setContextMenu(null)}
/>
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
</div>
{/* Кнопки снизу */}
<GraphControls
fgRef={fgRef}
onExport={onExport}
onDataChange={onDataChange}
/>
</div>
);
};
export default Graph;
@@ -0,0 +1,229 @@
import React, {
useRef,
useEffect,
useCallback,
useState,
forwardRef,
} from "react";
import ForceGraph2D from "react-force-graph-2d";
import type { GraphData, GraphNode, GraphLink } from "../types";
import { useGraphStore } from "../store/useGraphStore";
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
interface ForceGraphProps {
data: GraphData;
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
}
export const ForceGraph = forwardRef<any, ForceGraphProps>(
({ data, onNodeRightClick }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
const highlightNodes = useGraphStore((s) => s.highlightNodes);
const highlightLinks = useGraphStore((s) => s.highlightLinks);
const selectedNode = useGraphStore((s) => s.selectedNode);
const isLinkMode = useGraphStore((s) => s.isLinkMode);
const theme = useThemeStore((s) => s.theme);
const isDark = theme === "dark";
// Определяем цвета текста в зависимости от темы
const nodeTextColor = isDark ? "#e5e7eb" : "#1f2937";
const nodeTextLetterColor = isDark ? "#ffffff" : "#000000";
// ResizeObserver для корректного отслеживания размеров
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateDimensions = () => {
setDimensions({
width: container.clientWidth,
height: container.clientHeight,
});
};
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
observer.observe(container);
return () => observer.disconnect();
}, []);
const handleNodeClick = useCallback((node: GraphNode) => {
const store = useGraphStore.getState();
if (store.isLinkMode) {
if (store.selectedNode === null) {
store.setSelectedNode(node);
} else if (store.selectedNode.id !== node.id) {
store.createLink(store.selectedNode.id, node.id);
store.setSelectedNode(null);
store.toggleLinkMode();
} else {
store.setSelectedNode(null);
}
}
}, []);
const handleNodeHover = (node: GraphNode | null) => {
const newHighlightNodes = new Set<string>();
const newHighlightLinks = new Set<GraphLink>();
if (node) {
newHighlightNodes.add(node.id);
data.links.forEach((link) => {
if (link.source === node.id || link.target === node.id) {
newHighlightLinks.add(link);
newHighlightNodes.add(link.source as string);
newHighlightNodes.add(link.target as string);
}
});
}
useGraphStore
.getState()
.setHighlight(newHighlightNodes, newHighlightLinks);
};
const getNodeColor = (node: GraphNode) => {
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
if (node.type === "service" && node.status === "down") {
// Проверяем, есть ли зависимости этого сервиса, которые тоже упали
const hasDownDependency = data.links.some((link) => {
const sourceId =
typeof link.source === "object"
? (link.source as any).id
: link.source;
const targetId =
typeof link.target === "object"
? (link.target as any).id
: link.target;
if (sourceId !== node.id) return false;
const isDependency =
link.type === "dependency" || link.type === "started";
const targetIsDown = data.nodes.some(
(n) => n.id === targetId && n.status === "down",
);
return isDependency && targetIsDown;
});
// Если есть упавшая зависимость — не подсвечиваем красным
if (hasDownDependency) return "#3b82f6";
return "#ef4444";
}
if (node.type === "agent") {
// Проверяем, есть ли у агента хотя бы один упавший сервис
const hasDownService = data.nodes.some(
(n) =>
n.type === "service" &&
n.status === "down" &&
n.id.startsWith(`${node.id}-`),
);
if (hasDownService) return "#ef4444";
}
switch (node.type) {
case "service":
return "#3b82f6";
case "agent":
return "#8b5cf6";
default:
return "#6b7280";
}
};
const getNodeSize = (node: GraphNode) => {
switch (node.type) {
case "service":
return 3;
case "agent":
return 3;
default:
return 5;
}
};
const renderNode = (
node: GraphNode,
ctx: CanvasRenderingContext2D,
globalScale: number,
) => {
const size = getNodeSize(node);
const color = getNodeColor(node);
if (!node.x || !node.y) return;
ctx.beginPath();
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.fillStyle = nodeTextLetterColor;
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (node.type === "service") {
ctx.fillText("S", node.x, node.y);
} else if (node.type === "agent") {
ctx.fillText("A", node.x, node.y);
}
if (globalScale > 0.5) {
ctx.fillStyle = nodeTextColor;
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
ctx.textAlign = "center";
ctx.fillText(node.name, node.x, node.y + size + 8);
}
};
const handleEngineStop = () => {
if (typeof ref !== "function" && ref && "current" in ref && ref.current) {
ref.current.zoomToFit(400);
}
};
return (
<div ref={containerRef} className="w-full h-full relative">
<ForceGraph2D
ref={ref}
graphData={data}
width={dimensions.width}
height={dimensions.height}
nodeCanvasObject={renderNode}
nodeLabel={(node: GraphNode) => {
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
}}
linkLabel={(link: GraphLink) => {
const sourceName =
data.nodes.find((n) => n.id === link.source)?.name || link.source;
const targetName =
data.nodes.find((n) => n.id === link.target)?.name || link.target;
return `Связь: ${sourceName}${targetName}\nПКМ для удаления`;
}}
linkColor={(link: any) => {
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
}}
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
linkDirectionalParticles={0}
onNodeClick={handleNodeClick}
onNodeRightClick={onNodeRightClick}
onNodeHover={handleNodeHover}
cooldownTicks={50}
cooldownTime={2000}
d3AlphaDecay={0.03}
d3VelocityDecay={0.4}
warmupTicks={50}
onEngineStop={handleEngineStop}
/>
</div>
);
},
);
ForceGraph.displayName = "ForceGraph";
@@ -0,0 +1,86 @@
import React from "react";
import { FiLink, FiTrash2 } from "react-icons/fi";
import type { ContextMenuState, GraphNode, GraphData } from "../types";
import { useGraphStore } from "../store/useGraphStore";
interface GraphContextMenuProps {
menu: ContextMenuState | null;
data: GraphData;
onClose: () => void;
}
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
menu,
data,
onClose,
}) => {
const removeNode = useGraphStore((s) => s.removeNode);
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
if (!menu) return null;
const handleDeleteNode = (node: GraphNode) => {
removeNode(node.id);
onClose();
};
const handleCreateLink = (node: GraphNode) => {
toggleLinkMode();
setSelectedNode(node);
onClose();
};
return (
<div
className="fixed rounded-lg shadow-lg py-1 z-50"
style={{
top: menu.y,
left: menu.x,
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
}}
onClick={(e) => e.stopPropagation()}
>
{menu.node && (
<>
<div
className="px-3 py-1 text-xs border-b"
style={{
color: "var(--text-secondary)",
borderColor: "var(--border)",
}}
>
{menu.node.name}
</div>
<button
onClick={() => handleCreateLink(menu.node!)}
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
style={{ color: "var(--text-primary)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "var(--bg-secondary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "transparent")
}
>
<FiLink size={14} /> Создать связь
</button>
<button
onClick={() => handleDeleteNode(menu.node!)}
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
style={{ color: "#f87171" }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(248,113,113,0.1)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "transparent")
}
>
<FiTrash2 size={14} /> Удалить узел
</button>
</>
)}
</div>
);
};
@@ -0,0 +1,105 @@
import React from "react";
import {
FiDownload,
FiZoomIn,
FiZoomOut,
FiMove,
FiLink,
} from "react-icons/fi";
import { useGraphStore } from "../store/useGraphStore";
import type { GraphData } from "../types";
interface GraphControlsProps {
fgRef: React.RefObject<any>;
onExport?: () => void;
onDataChange?: (data: GraphData) => void;
}
const btnStyle: React.CSSProperties = {
backgroundColor: "var(--bg-secondary)",
color: "var(--text-primary)",
};
export const GraphControls: React.FC<GraphControlsProps> = ({
fgRef,
onExport,
onDataChange,
}) => {
const isLinkMode = useGraphStore((s) => s.isLinkMode);
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
const exportData = useGraphStore((s) => s.exportData);
const handleZoomIn = () => {
if (fgRef.current) {
const currentZoom = fgRef.current.zoom();
fgRef.current.zoom(currentZoom * 1.2);
}
};
const handleZoomOut = () => {
if (fgRef.current) {
const currentZoom = fgRef.current.zoom();
fgRef.current.zoom(currentZoom / 1.2);
}
};
const handleFit = () => {
if (fgRef.current) {
fgRef.current.zoomToFit(400);
}
};
return (
<div className="flex items-center justify-end gap-2 mt-2">
{/* Режим создания связи */}
{/* <button
onClick={toggleLinkMode}
className="flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
style={{
backgroundColor: isLinkMode ? "#22c55e" : "var(--bg-secondary)",
color: isLinkMode ? "#fff" : "var(--text-primary)",
}}
>
<FiLink />
<span>{isLinkMode ? "Создание связи..." : "Добавить связь"}</span>
</button> */}
{/* Зум + */}
<button
onClick={handleZoomIn}
className="p-2 rounded-lg transition-colors"
style={btnStyle}
>
<FiZoomIn />
</button>
{/* Зум - */}
<button
onClick={handleZoomOut}
className="p-2 rounded-lg transition-colors"
style={btnStyle}
>
<FiZoomOut />
</button>
{/* Fit */}
<button
onClick={handleFit}
className="p-2 rounded-lg transition-colors"
style={btnStyle}
>
<FiMove />
</button>
{/* Экспорт */}
<button
onClick={onExport || exportData}
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
style={btnStyle}
>
<FiDownload />
<span>Экспорт</span>
</button>
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import type { GraphData } from "../types";
interface GraphStatsProps {
data: GraphData;
}
export const GraphStats: React.FC<GraphStatsProps> = ({ data }) => {
return (
<div
className="flex gap-4 text-xs"
style={{ color: "var(--text-secondary)" }}
>
<span>
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
</span>
<span>Агенты: {data.nodes.filter((n) => n.type === "agent").length}</span>
<div className="flex items-center gap-1.5">
<div
className="w-2 h-2 rounded-sm"
style={{ backgroundColor: "var(--text-muted)" }}
></div>
<span>Связи: {data.links.length}</span>
</div>
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import { FiLink } from "react-icons/fi";
import type { GraphNode } from "../types";
interface GraphStatusBarProps {
isLinkMode: boolean;
selectedNode: GraphNode | null;
}
export const GraphStatusBar: React.FC<GraphStatusBarProps> = ({
isLinkMode,
selectedNode,
}) => {
if (!isLinkMode) return null;
return (
<div
className="absolute bottom-4 left-4 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2"
style={{ backgroundColor: "#22c55e" }}
>
<FiLink /> Режим создания связей: кликните на два узла для соединения
{selectedNode && (
<span className="ml-2">Выбран: {selectedNode.name}</span>
)}
</div>
);
};
@@ -0,0 +1,5 @@
export { ForceGraph } from "./ForceGraph";
export { GraphControls } from "./GraphControls";
export { GraphContextMenu } from "./GraphContextMenu";
export { GraphStatusBar } from "./GraphStatusBar";
export { GraphStats } from "./GraphStats";
+3
View File
@@ -0,0 +1,3 @@
export { Graph } from "./Graph";
export { useGraphStore } from "./store/useGraphStore";
export type { GraphData, GraphNode, GraphLink } from "./types";
@@ -0,0 +1,113 @@
import { create } from "zustand";
import type { GraphData, GraphNode, GraphLink } from "../types";
interface GraphState {
data: GraphData;
highlightNodes: Set<string>;
highlightLinks: Set<GraphLink>;
isLinkMode: boolean;
selectedNode: GraphNode | null;
// Действия с данными
setData: (data: GraphData) => void;
addNode: (node: GraphNode) => void;
removeNode: (nodeId: string) => void;
addLink: (link: GraphLink) => void;
removeLink: (link: GraphLink) => void;
// Подсветка
setHighlight: (nodeIds: Set<string>, links: Set<GraphLink>) => void;
// Режим связи
toggleLinkMode: () => void;
setSelectedNode: (node: GraphNode | null) => void;
createLink: (sourceId: string, targetId: string) => void;
// Экспорт
exportData: () => void;
}
export const useGraphStore = create<GraphState>((set, get) => ({
data: { nodes: [], links: [] },
highlightNodes: new Set(),
highlightLinks: new Set(),
isLinkMode: false,
selectedNode: null,
setData: (data) => set({ data }),
addNode: (node) => {
set((state) => ({
data: {
...state.data,
nodes: [...state.data.nodes, node],
},
}));
},
removeNode: (nodeId) => {
set((state) => ({
data: {
nodes: state.data.nodes.filter((n) => n.id !== nodeId),
links: state.data.links.filter(
(l) => l.source !== nodeId && l.target !== nodeId,
),
},
}));
},
addLink: (link) => {
set((state) => ({
data: {
...state.data,
links: [...state.data.links, link],
},
}));
},
removeLink: (linkToRemove) => {
set((state) => ({
data: {
...state.data,
links: state.data.links.filter((l) => l !== linkToRemove),
},
}));
},
setHighlight: (nodeIds, links) =>
set({ highlightNodes: nodeIds, highlightLinks: links }),
toggleLinkMode: () =>
set((state) => ({
isLinkMode: !state.isLinkMode,
selectedNode: null,
})),
setSelectedNode: (node) => set({ selectedNode: node }),
createLink: (sourceId, targetId) => {
const { data, addLink } = get();
const linkExists = data.links.some(
(link) =>
(link.source === sourceId && link.target === targetId) ||
(link.source === targetId && link.target === sourceId),
);
if (!linkExists) {
addLink({ source: sourceId, target: targetId, type: "custom" });
}
},
exportData: () => {
const { data } = get();
const dataStr = JSON.stringify(data, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "graph-data.json";
link.click();
URL.revokeObjectURL(url);
},
}));
+50
View File
@@ -0,0 +1,50 @@
export interface GraphNode {
id: string;
name: string;
type: "agent" | "service";
val?: number;
description?: string;
x?: number;
y?: number;
status?: "up" | "down";
}
export interface GraphLink {
source: string;
target: string;
type?: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface ContextMenuState {
x: number;
y: number;
node: GraphNode | null;
link: GraphLink | null;
}
// API response types for GET /graph
export interface GraphDependencyTarget {
name: string;
}
export interface GraphDependency {
condition: string;
target: GraphDependencyTarget;
}
export interface GraphServiceNode {
dependencies: GraphDependency[];
}
export interface GraphAgentNode {
services: Record<string, GraphServiceNode>;
}
export interface GraphApiResponse {
nodes: Record<string, GraphAgentNode>;
}
+338
View File
@@ -0,0 +1,338 @@
import React, { useEffect } from "react";
import { MdAdd, MdArrowBack } from "react-icons/md";
import { GoTrash } from "react-icons/go";
import {
useIDEStore,
initialFiles as defaultInitialFiles,
} from "./store/useIDEStore";
import type { FileNode } from "./types";
import {
FileExplorer,
TabBar,
CodeEditor,
TitleBar,
StatusBar,
} from "./components";
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
interface IDEProps {
initialFiles?: FileNode;
onBack?: () => void;
}
const darkColors = {
bg: "#1e1e1e",
bgSecondary: "#252526",
bgTertiary: "#2d2d30",
border: "#3e3e42",
textPrimary: "#cccccc",
textSecondary: "#858585",
accent: "#0e639c",
accentHover: "#1177bb",
statusBar: "#007acc",
};
const lightColors = {
bg: "#ffffff",
bgSecondary: "#f3f3f3",
bgTertiary: "#e8e8e8",
border: "#e0e0e0",
textPrimary: "#333333",
textSecondary: "#616161",
accent: "#0e639c",
accentHover: "#1177bb",
statusBar: "#007acc",
};
export const IDE: React.FC<IDEProps> = ({
initialFiles: externalFiles,
onBack,
}: IDEProps = {}) => {
const theme = useThemeStore((s) => s.theme);
const isDark = theme === "dark";
const c = isDark ? darkColors : lightColors;
const files = useIDEStore((state) => state.files);
const openFiles = useIDEStore((state) => state.openFiles);
const activeFile = useIDEStore((state) => state.activeFile);
const createNewProject = useIDEStore((state) => state.createNewProject);
const selectFile = useIDEStore((state) => state.selectFile);
const updateFileContent = useIDEStore((state) => state.updateFileContent);
const saveActiveFile = useIDEStore((state) => state.saveActiveFile);
const closeFile = useIDEStore((state) => state.closeFile);
const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
const initialize = useIDEStore((state) => state.initialize);
const isInitialized = useIDEStore((state) => state.isInitialized);
const fetchTree = useIDEStore((state) => state.fetchTree);
const fetchInterpreters = useIDEStore((state) => state.fetchInterpreters);
// Загружаем интерпретаторы при инициализации
useEffect(() => {
fetchInterpreters();
}, []);
// Обработка Ctrl+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
saveActiveFile();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [saveActiveFile]);
// При загрузке пробуем загрузить дерево с сервера
useEffect(() => {
if (!isInitialized) {
fetchTree().catch(() => {
// Только при ошибке — используем моковые данные
const state = useIDEStore.getState();
if (!state.files) {
const filesToInit = externalFiles || defaultInitialFiles;
initialize(filesToInit);
}
});
}
}, [isInitialized]);
// Если проект не открыт
if (!files) {
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
backgroundColor: c.bg,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
position: "relative",
}}
>
<TitleBar />
{onBack && (
<button
onClick={onBack}
style={{
position: "absolute",
top: "40px",
left: "12px",
background: "transparent",
border: `1px solid ${c.border}`,
color: c.textPrimary,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "6px",
fontSize: "12px",
transition: "all 0.1s",
zIndex: 10,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = c.border;
e.currentTarget.style.color = "#fff";
e.currentTarget.style.borderColor = "#555";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = c.textPrimary;
e.currentTarget.style.borderColor = c.border;
}}
title="Go back"
>
<MdArrowBack size={16} />
<span>Back</span>
</button>
)}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ textAlign: "center" }}>
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "center",
opacity: 0.3,
}}
>
<GoTrash size={72} />
</div>
<div
style={{
fontSize: "22px",
marginBottom: "12px",
color: c.textPrimary,
fontWeight: 300,
}}
>
No project open
</div>
<div
style={{
fontSize: "13px",
marginBottom: "32px",
color: c.textSecondary,
}}
>
Create a new project to get started
</div>
<button
onClick={createNewProject}
style={{
padding: "10px 24px",
backgroundColor: c.accent,
border: "none",
borderRadius: "4px",
color: "#fff",
cursor: "pointer",
fontSize: "13px",
fontWeight: 500,
transition: "background-color 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = c.accentHover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = c.accent;
}}
>
<MdAdd size={14} /> New Project
</button>
</div>
</div>
<StatusBar activeFile={null} />
</div>
);
}
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
backgroundColor: c.bg,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
}}
>
<div
style={{
height: "30px",
backgroundColor: c.bgTertiary,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 8px",
borderBottom: `1px solid ${c.bg}`,
fontSize: "12px",
color: c.textPrimary,
userSelect: "none",
flexShrink: 0,
}}
>
{onBack && (
<button
onClick={onBack}
style={{
background: "transparent",
border: "none",
color: c.textPrimary,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "11px",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = c.border;
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = c.textPrimary;
}}
title="Go back"
>
<MdArrowBack size={14} />
<span>Back</span>
</button>
)}
{!onBack && <div />}
<span style={{ fontWeight: 400 }}>
{activeFile
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
: ""}
{files.name}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{activeFile?.dirty && (
<button
onClick={saveActiveFile}
style={{
background: "transparent",
border: "none",
color: c.textPrimary,
cursor: "pointer",
fontSize: "11px",
padding: "4px 8px",
borderRadius: "4px",
}}
title="Сохранить (Ctrl+S)"
>
Сохранить
</button>
)}
</div>
</div>
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<div style={{ width: "260px", flexShrink: 0 }}>
<FileExplorer
files={files}
onDeleteRoot={useIDEStore.getState().deleteRoot}
/>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<TabBar
openFiles={openFiles}
activeFile={activeFile}
onSelectFile={selectFile}
onCloseFile={closeFile}
onCloseAll={closeAllFiles}
onCloseOthers={closeOtherFiles}
/>
<CodeEditor
filePath={activeFile?.path || ""}
content={activeFile?.content || ""}
onChange={updateFileContent}
/>
</div>
</div>
<StatusBar activeFile={activeFile} />
</div>
);
};
export default IDE;
+138
View File
@@ -0,0 +1,138 @@
import { apiClient } from "@/shared/api/axios.instance";
import type { Interpreter } from "../types";
export interface ScriptNodeDto {
id: number;
name: string;
type: "file" | "folder";
content?: string;
children?: string[];
interpreter_id?: number;
}
export interface ScriptResponse {
id: number;
content: string;
interpreter_id: number;
path: string;
created_at: string;
updated_at: string;
}
export interface CreateScriptPayload {
content: string;
interpreter_id: number;
path: string;
}
export interface UpdateScriptPayload {
content: string;
interpreter_id: number;
path: string;
}
export interface RunScriptPayload {
stdin?: string;
token: string;
}
export interface RunScriptResponse {
command: string[];
id: number;
wait_url: string;
}
export interface CreateInterpreterPayload {
argv: string[];
label: string;
name: string;
}
export interface JobWaitResponse {
command: string[];
id: number;
status: number;
stderr: string;
stdin: string;
stdout: string;
}
// apiClient уже имеет интерсептор для Authorization header
export const scriptsApi = {
getInterpreters: async (): Promise<Interpreter[]> => {
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
return res.data;
},
getTree: async (): Promise<ScriptNodeDto[]> => {
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
return res.data;
},
createScript: async (
payload: CreateScriptPayload,
): Promise<ScriptResponse> => {
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
return res.data;
},
updateScript: async (
id: number,
payload: UpdateScriptPayload,
): Promise<ScriptResponse> => {
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
return res.data;
},
deleteScript: async (id: number): Promise<void> => {
await apiClient.delete(`/scripts/${id}`);
},
createFolder: async (path: string): Promise<{ path: string }> => {
const res = await apiClient.post<{ path: string }>("/scripts/folder", {
path,
});
return res.data;
},
deleteFolder: async (path: string): Promise<void> => {
await apiClient.delete(`/scripts/folder`, { data: { path } });
},
rename: async (payload: {
old_path: string;
new_path: string;
}): Promise<{ path: string }> => {
const res = await apiClient.post<{ path: string }>(
"/scripts/rename",
payload,
);
return res.data;
},
runScript: async (
id: number,
payload: RunScriptPayload,
): Promise<RunScriptResponse> => {
const res = await apiClient.post<RunScriptResponse>(
`/scripts/${id}/run`,
payload,
);
return res.data;
},
waitJob: async (id: number): Promise<JobWaitResponse> => {
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
return res.data;
},
createInterpreter: async (
payload: CreateInterpreterPayload,
): Promise<Interpreter> => {
const res = await apiClient.post<Interpreter>(
"/scripts/interpreters",
payload,
);
return res.data;
},
};
@@ -0,0 +1,276 @@
import React, { useState, useRef } from "react";
import { MdClose, MdAdd } from "react-icons/md";
import { scriptsApi } from "../api/scripts.api";
import type { CreateInterpreterPayload } from "../api/scripts.api";
interface AddInterpreterModalProps {
onClose: () => void;
onSuccess: () => void;
}
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
onClose,
onSuccess,
}) => {
const [name, setName] = useState("");
const [label, setLabel] = useState("");
const [argv, setArgv] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
React.useEffect(() => {
nameRef.current?.focus();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !label.trim()) {
setError("Name and Label are required");
return;
}
setLoading(true);
setError(null);
try {
const payload: CreateInterpreterPayload = {
name: name.trim(),
label: label.trim(),
argv: argv
.split(" ")
.map((s) => s.trim())
.filter(Boolean),
};
await scriptsApi.createInterpreter(payload);
onSuccess();
onClose();
} catch (e: any) {
console.error("Failed to create interpreter:", e);
setError(e?.response?.data?.detail || "Failed to create interpreter");
} finally {
setLoading(false);
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
width: "420px",
maxWidth: "90vw",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
}}
>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: "14px",
fontWeight: 600,
}}
>
Add Interpreter
</h3>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "var(--text-secondary)",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
}}
>
<MdClose size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
{/* Name */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Name <span style={{ color: "#f44747" }}>*</span>
</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Python, Node.js, etc."
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Label */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Label <span style={{ color: "#f44747" }}>*</span>
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="python3, node, etc."
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Args */}
<div style={{ marginBottom: "16px" }}>
<label
style={{
display: "block",
color: "var(--text-secondary)",
fontSize: "12px",
marginBottom: "6px",
}}
>
Arguments <span style={{ color: "#858585" }}>(optional)</span>
</label>
<input
type="text"
value={argv}
onChange={(e) => setArgv(e.target.value)}
placeholder="-u -O (space separated)"
style={{
width: "100%",
padding: "8px 12px",
backgroundColor: "var(--input-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
color: "var(--text-primary)",
fontSize: "13px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Error */}
{error && (
<div
style={{
padding: "8px 12px",
backgroundColor: "rgba(244, 71, 71, 0.1)",
border: "1px solid #f44747",
borderRadius: "4px",
color: "#f44747",
fontSize: "12px",
marginBottom: "16px",
}}
>
{error}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "10px",
backgroundColor: loading ? "#555" : "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
fontSize: "13px",
fontWeight: 500,
cursor: loading ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
}}
>
{loading ? (
<>
<span
style={{
display: "inline-block",
animation: "spin 1s linear infinite",
}}
>
</span>
Creating...
</>
) : (
<>
<MdAdd size={16} />
Add Interpreter
</>
)}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import React from "react";
import Editor from "@monaco-editor/react";
import { FiFolder } from "react-icons/fi";
import { getLanguage } from "../helpers/fileTree";
interface CodeEditorProps {
filePath: string;
content: string;
onChange: (content: string) => void;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
filePath,
content,
onChange,
}) => {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#1e1e1e",
}}
>
<div style={{ flex: 1 }}>
{filePath ? (
<Editor
height="100%"
language={getLanguage(filePath)}
value={content}
onChange={(value) => onChange(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
tabSize: 4,
wordWrap: "on",
lineNumbers: "on",
automaticLayout: true,
renderWhitespace: "selection",
smoothScrolling: true,
}}
/>
) : (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "#858585",
textAlign: "center",
}}
>
<div>
<div
style={{
marginBottom: "24px",
display: "flex",
justifyContent: "center",
opacity: 0.5,
}}
>
<FiFolder size={64} />
</div>
<div
style={{
fontSize: "18px",
marginBottom: "12px",
color: "#cccccc",
}}
>
Welcome to Web VS Code
</div>
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
Right-click on a folder to create files
</div>
<div style={{ fontSize: "12px", color: "#0e639c" }}>
Or right-click anywhere in the explorer
</div>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,99 @@
import React, { useEffect } from "react";
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
const MenuItem: React.FC<{
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}> = ({ onClick, danger, children }) => (
<div
onClick={onClick}
style={{
padding: "8px 16px",
cursor: "pointer",
color: danger ? "#f48771" : "#cccccc",
fontSize: "13px",
transition: "background-color 0.1s",
display: "flex",
alignItems: "center",
gap: "10px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{children}
</div>
);
interface ContextMenuProps {
x: number;
y: number;
onClose: () => void;
onNewFile: () => void;
onNewFolder: () => void;
onRename: () => void;
onDelete: () => void;
hasNode: boolean;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
onClose,
onNewFile,
onNewFolder,
onRename,
onDelete,
hasNode,
}) => {
useEffect(() => {
const handleClick = () => onClose();
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [onClose]);
return (
<div
style={{
position: "fixed",
top: y,
left: x,
backgroundColor: "#252526",
border: "1px solid #3e3e42",
borderRadius: "6px",
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
zIndex: 1000,
minWidth: "180px",
overflow: "hidden",
}}
>
<MenuItem onClick={onNewFile}>
<FiFile /> New File
</MenuItem>
<MenuItem onClick={onNewFolder}>
<FiFolder /> New Folder
</MenuItem>
{hasNode && (
<>
<div
style={{
height: "1px",
backgroundColor: "#3e3e42",
margin: "4px 0",
}}
/>
<MenuItem onClick={onRename}>
<FiEdit3 /> Rename
</MenuItem>
<MenuItem onClick={onDelete} danger>
<FiTrash2 /> Delete
</MenuItem>
</>
)}
</div>
);
};
@@ -0,0 +1,345 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
import { GoKebabHorizontal } from "react-icons/go";
import { MdClose, MdAdd } from "react-icons/md";
import { FileTreeItem } from "./FileTreeItem";
import { ContextMenu } from "./ContextMenu";
import { InputDialog } from "./InputDialog";
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
import { useIDEStore } from "../store/useIDEStore";
import type { FileNode } from "../types";
interface FileExplorerProps {
files: FileNode;
onDeleteRoot: () => void;
}
export const FileExplorer: React.FC<FileExplorerProps> = ({
files,
onDeleteRoot,
}) => {
const store = useIDEStore();
const [showSearch, setShowSearch] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Фокус на инпут при открытии поиска
useEffect(() => {
if (showSearch) {
searchInputRef.current?.focus();
}
}, [showSearch]);
const handleSearchBlur = useCallback(() => {
// Скрываем поиск при потере фокуса с небольшой задержкой,
// чтобы клики по кнопке очистки успели сработать
setTimeout(() => {
if (
searchInputRef.current &&
!searchInputRef.current.contains(document.activeElement)
) {
setShowSearch(false);
store.setSearchQuery("");
}
}, 100);
}, [store]);
const handleEmptyContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Загружаем интерпретаторы перед открытием меню
if (store.interpreters.length === 0) {
store.fetchInterpreters();
}
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
};
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
e.preventDefault();
e.stopPropagation();
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
};
// Загружаем интерпретаторы при монтировании компонента
useEffect(() => {
if (store.interpreters.length === 0) {
store.fetchInterpreters();
}
}, []);
const filteredFiles = store.searchQuery
? (files.children || [])
.map((child) => filterTree(child, store.searchQuery))
.filter((child): child is FileNode => child !== null)
: files.children || [];
useEffect(() => {
if (store.searchQuery && files) {
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
if (pathsToExpand.size > 0) {
store.autoExpandPaths(pathsToExpand);
}
}
}, [store.searchQuery, files, store.autoExpandPaths]);
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#252526",
}}
onContextMenu={handleEmptyContextMenu}
>
<div
style={{
padding: "0 8px",
height: "35px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "1px solid #3e3e42",
}}
>
<span
style={{
color: "#bbbbbb",
fontWeight: 500,
fontSize: "11px",
letterSpacing: "0.8px",
}}
>
EXPLORER
</span>
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
<button
onClick={() => {
if (!showSearch) {
setShowSearch(true);
} else {
setShowSearch(false);
store.setSearchQuery("");
}
}}
style={{
background: "transparent",
border: "none",
color: showSearch ? "#cccccc" : "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
title="Search in files"
>
<FiSearch size={14} />
</button>
<button
onClick={store.collapseAllFolders}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
e.currentTarget.style.color = "#cccccc";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#858585";
}}
title="Collapse All"
>
<FiMinus size={14} />
</button>
<button
onClick={store.expandAllFolders}
style={{
background: "transparent",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2a2d2e";
e.currentTarget.style.color = "#cccccc";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "#858585";
}}
title="Expand All"
>
<GoKebabHorizontal size={14} />
</button>
</div>
</div>
{showSearch && (
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "#3c3c3c",
border: store.searchQuery
? "1px solid #007acc"
: "1px solid transparent",
borderRadius: "4px",
padding: "0 6px",
transition: "border-color 0.1s",
}}
>
<FiSearch size={13} color="#858585" />
<input
ref={searchInputRef}
type="text"
value={store.searchQuery}
onChange={(e) => store.setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Search..."
style={{
flex: 1,
padding: "5px 6px",
backgroundColor: "transparent",
border: "none",
color: "#cccccc",
fontSize: "12px",
outline: "none",
}}
/>
{store.searchQuery && (
<button
onClick={() => store.setSearchQuery("")}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
}}
>
<MdClose size={12} />
</button>
)}
</div>
</div>
)}
<div style={{ flex: 1, overflowY: "auto" }}>
{filteredFiles.length > 0 ? (
filteredFiles.map((child, idx) => (
<FileTreeItem
key={idx}
node={child}
level={0}
onFileSelect={store.selectFile}
selectedFile={store.activeFile?.path || null}
onContextMenu={handleNodeContextMenu}
expandedFolders={store.expandedFolders}
onToggleFolder={store.toggleFolder}
onDelete={store.handleDeleteNode}
searchQuery={store.searchQuery}
/>
))
) : (
<div
style={{
padding: "16px",
color: "#858585",
fontSize: "13px",
textAlign: "center",
}}
>
No results found
</div>
)}
</div>
{store.contextMenu && (
<ContextMenu
x={store.contextMenu.x}
y={store.contextMenu.y}
onClose={() => store.setContextMenu(null)}
onNewFile={() => {
store.setDialog({
type: "newFile",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onNewFolder={() => {
store.setDialog({
type: "newFolder",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onRename={() => {
store.setDialog({
type: "rename",
node: store.contextMenu?.node || null,
});
store.setContextMenu(null);
}}
onDelete={() => {
if (store.contextMenu?.node) {
store.handleDeleteNode(store.contextMenu.node);
}
store.setContextMenu(null);
}}
hasNode={!!store.contextMenu.node}
/>
)}
{store.dialog && (
<InputDialog
title={
store.dialog.type === "newFile"
? "New File"
: store.dialog.type === "newFolder"
? "New Folder"
: "Rename"
}
initialValue={
store.dialog.type === "rename" && store.dialog.node
? store.dialog.node.name
: ""
}
onConfirm={(value, interpreterId) => {
store.handleDialogConfirm(value, interpreterId);
}}
onCancel={() => store.setDialog(null)}
interpreters={
store.dialog.type === "newFile" ? store.interpreters : undefined
}
/>
)}
</div>
);
};
@@ -0,0 +1,83 @@
import React from "react";
import type { FileNode } from "../types";
import { FilePickerItem } from "./FilePickerItem";
import { useFilePickerStore } from "../store/useFilePickerStore";
import { TerminalOutput } from "@/modules/terminal";
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
interface FilePickerProps {
files: FileNode;
onRun?: (path: string) => void;
}
const FilePickerTree: React.FC<{
node: FileNode;
level: number;
onRun?: (path: string) => void;
}> = ({ node, level, onRun }) => {
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
const nodePath = node.path || node.name;
const isExpanded = expandedFolders.has(nodePath);
if (node.type === "file") {
return (
<FilePickerItem
name={node.name}
type="file"
path={nodePath}
level={level}
onRun={onRun}
/>
);
}
return (
<>
<FilePickerItem
name={node.name}
type="folder"
path={nodePath}
isExpanded={isExpanded}
level={level}
onToggleFolder={toggleFolder}
>
{node.children?.map((child, idx) => (
<FilePickerTree
key={idx}
node={child}
level={level + 1}
onRun={onRun}
/>
))}
</FilePickerItem>
</>
);
};
export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
const terminalOpen = useTerminalStore((s) => s.isOpen);
const jobs = useTerminalStore((s) => s.jobs);
return (
<div
style={{
height: "100%",
overflowY: "auto",
backgroundColor: "var(--bg-primary)",
}}
>
{/* Terminal — сверху, над списком файлов */}
{terminalOpen && jobs.length > 0 && (
<div style={{ height: 250 }}>
<TerminalOutput />
</div>
)}
{(files.children || []).map((child, idx) => (
<FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
))}
</div>
);
};
@@ -0,0 +1,165 @@
import React from "react";
import {
FiChevronRight,
FiChevronDown,
FiFile,
FiFolder,
FiPlay,
} from "react-icons/fi";
interface FilePickerItemProps {
name: string;
type: "file" | "folder";
path: string;
isExpanded?: boolean;
children?: React.ReactNode;
level: number;
onToggleSelect?: (path: string) => void;
onToggleFolder?: (path: string) => void;
onRun?: (path: string) => void;
}
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
name,
type,
path,
isExpanded,
children,
level,
onToggleSelect,
onToggleFolder,
onRun,
}) => {
const isFolder = type === "folder";
const extension = name.includes(".")
? name.split(".").pop()?.toUpperCase()
: "";
const paddingLeft = 12 + level * 20;
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: `${paddingLeft}px`,
paddingRight: "12px",
height: "36px",
borderBottom: "1px solid var(--border)",
cursor: "pointer",
transition: "background-color 0.1s",
gap: "8px",
}}
onClick={() => {
if (isFolder && onToggleFolder) {
onToggleFolder(path);
} else if (!isFolder && onToggleSelect) {
onToggleSelect(path);
}
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{/* Folder expand icon */}
{isFolder && (
<span
style={{
color: "var(--text-secondary)",
display: "flex",
flexShrink: 0,
}}
>
{isExpanded ? (
<FiChevronDown size={14} />
) : (
<FiChevronRight size={14} />
)}
</span>
)}
{/* File/Folder icon */}
<span style={{ display: "flex", flexShrink: 0 }}>
{isFolder ? (
<FiFolder size={15} color="var(--accent)" />
) : (
<FiFile size={15} color="var(--text-secondary)" />
)}
</span>
{/* Name */}
<span
style={{
flex: 1,
color: "var(--text-primary)",
fontSize: "13px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
</span>
{/* Extension badge — только у файлов */}
{!isFolder && extension && (
<span
style={{
color: "var(--text-secondary)",
fontSize: "11px",
fontFamily: "monospace",
padding: "2px 6px",
backgroundColor: "var(--bg-secondary)",
borderRadius: "3px",
flexShrink: 0,
}}
>
{extension}
</span>
)}
{/* Run button — только у файлов */}
{!isFolder && onRun && (
<button
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px",
backgroundColor: "transparent",
border: "1px solid transparent",
borderRadius: "3px",
color: "var(--text-secondary)",
cursor: "pointer",
flexShrink: 0,
transition: "all 0.15s",
}}
onClick={(e) => {
e.stopPropagation();
onRun(path);
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#238636";
e.currentTarget.style.color = "#ffffff";
e.currentTarget.style.borderColor = "#2ea043";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = "var(--text-secondary)";
e.currentTarget.style.borderColor = "transparent";
}}
title="Run script"
>
<FiPlay size={12} />
</button>
)}
</div>
{/* Children */}
{isFolder && isExpanded && children}
</div>
);
};
@@ -0,0 +1,169 @@
import React, { useState } from "react";
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
import { GoFile } from "react-icons/go";
import type { FileNode } from "../types";
interface FileTreeItemProps {
node: FileNode;
level: number;
onFileSelect: (node: FileNode) => void;
selectedFile: string | null;
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
onDelete: (node: FileNode) => void;
isRoot?: boolean;
searchQuery?: string;
}
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
node,
level,
onFileSelect,
selectedFile,
onContextMenu,
expandedFolders,
onToggleFolder,
onDelete,
isRoot,
searchQuery,
}) => {
const isFolder = node.type === "folder";
const isSelected = selectedFile === node.path && !isFolder;
const isExpanded = expandedFolders.has(node.path || node.name);
const [hovered, setHovered] = useState(false);
const handleClick = () => {
if (isFolder) {
onToggleFolder(node.path || node.name);
} else {
onFileSelect(node);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete(node);
};
const highlightText = (text: string, query: string) => {
if (!query) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
{text.slice(idx, idx + query.length)}
</span>
{text.slice(idx + query.length)}
</>
);
};
return (
<div>
<div
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, node)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
paddingTop: "4px",
paddingBottom: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
backgroundColor: isSelected ? "#094771" : "transparent",
color: isSelected ? "#fff" : "#cccccc",
fontSize: "13px",
transition: "background-color 0.1s",
userSelect: "none",
minHeight: "28px",
}}
>
<span
style={{
fontSize: "14px",
width: "16px",
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{isFolder ? (
isExpanded ? (
<FiChevronDown />
) : (
<FiChevronRight />
)
) : (
<GoFile />
)}
</span>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
</span>
{hovered && !isRoot && (
<button
onClick={handleDelete}
title={`Delete ${node.name}`}
style={{
background: "none",
border: "none",
color: "#858585",
cursor: "pointer",
padding: "2px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "3px",
flexShrink: 0,
width: "20px",
height: "20px",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "#f48771";
e.currentTarget.style.backgroundColor = "#3e3e42";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "#858585";
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FiTrash2 size={13} />
</button>
)}
</div>
{isFolder && isExpanded && node.children && (
<div>
{node.children.map((child, idx) => (
<FileTreeItem
key={idx}
node={child}
level={level + 1}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
onContextMenu={onContextMenu}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
onDelete={onDelete}
searchQuery={searchQuery}
/>
))}
</div>
)}
</div>
);
};
@@ -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";
};
+5
View File
@@ -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);
}
}
},
}));
+34
View File
@@ -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>
);
};
+1
View File
@@ -0,0 +1 @@
export { TerminalOutput } from "./components/TerminalOutput";
@@ -0,0 +1,75 @@
import { create } from "zustand";
export interface TerminalJob {
id: number;
scriptPath: string;
command: string[];
status: number | null;
stdout: string;
stderr: string;
stdin: string;
isRunning: boolean;
}
interface TerminalState {
jobs: TerminalJob[];
isOpen: boolean;
activeJobId: number | null;
openTerminal: () => void;
closeTerminal: () => void;
addJob: (job: Omit<TerminalJob, "status" | "stdout" | "stderr" | "isRunning">) => void;
updateJob: (id: number, updates: Partial<TerminalJob>) => void;
setActiveJob: (id: number | null) => void;
clearJobs: () => void;
removeJob: (id: number) => void;
}
export const useTerminalStore = create<TerminalState>((set) => ({
jobs: [],
isOpen: false,
activeJobId: null,
openTerminal: () => set({ isOpen: true }),
closeTerminal: () => set({ isOpen: false }),
addJob: (job) =>
set((state) => ({
jobs: [
...state.jobs,
{
...job,
status: null,
stdout: "",
stderr: "",
stdin: "",
isRunning: true,
},
],
activeJobId: job.id,
})),
updateJob: (id, updates) =>
set((state) => ({
jobs: state.jobs.map((j) => (j.id === id ? { ...j, ...updates } : j)),
})),
setActiveJob: (id) => set({ activeJobId: id }),
clearJobs: () => set({ jobs: [], activeJobId: null }),
removeJob: (id) =>
set((state) => {
const newJobs = state.jobs.filter((j) => j.id !== id);
return {
jobs: newJobs,
activeJobId:
state.activeJobId === id
? newJobs.length > 0
? newJobs[newJobs.length - 1].id
: null
: state.activeJobId,
};
}),
}));
+71 -24
View File
@@ -1,20 +1,24 @@
import React, { useState } from "react";
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
import { FiPlusCircle, FiSend } from "react-icons/fi";
interface SSHAgentConfig {
user: string;
ip: string;
authMethod: string;
sshKey?: string;
password?: string;
extraFields: { key: string; value: string }[];
deployType: string;
}
import { agentApiService } from "../modules/agent/api/agent.api.service";
import type { SSHAgentConfig } from "../modules/agent/ui/SSHAgentForm";
import type {
DeployAgentsRequest,
DeployResult,
} from "../modules/agent/types/agent.types";
import {
FiPlusCircle,
FiSend,
FiCheck,
FiX,
FiAlertCircle,
} from "react-icons/fi";
const createEmptyAgentConfig = (): SSHAgentConfig => ({
agentLabel: "",
user: "",
ip: "",
port: 22,
authMethod: "key",
sshKey: "",
password: "",
@@ -50,7 +54,9 @@ export const AddAgentsPage: React.FC = () => {
// Валидация
const isValid = agents.every((agent) => {
if (!agent.user || !agent.ip) return false;
if (!agent.agentLabel || !agent.user || !agent.ip || !agent.port)
return false;
if (agent.port < 1 || agent.port > 65535) return false;
if (agent.authMethod === "key" && !agent.sshKey) return false;
if (agent.authMethod === "password" && !agent.password) return false;
return true;
@@ -66,18 +72,53 @@ export const AddAgentsPage: React.FC = () => {
setSubmitError(null);
try {
// TODO: Реальный API вызов для развертывания агентов
console.log("Deploying agents:", agents);
// Преобразуем данные из формы в формат API
const deployData: DeployAgentsRequest = {
servers: agents.map((agent) => ({
agentLabel: agent.agentLabel,
ip: agent.ip,
user: agent.user,
port: agent.port,
authMethod: agent.authMethod as "key" | "password",
deployType: (agent.deployType === "deploy"
? "docker"
: agent.deployType) as "docker" | "binary",
...(agent.authMethod === "key"
? { sshKey: agent.sshKey }
: { password: agent.password }),
})),
};
// Имитация задержки API
await new Promise((resolve) => setTimeout(resolve, 1500));
// Вызываем API для развертывания агентов
const response = await agentApiService.deployAgents(deployData);
setSubmitMessage(
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`,
);
setAgents([createEmptyAgentConfig()]);
// Формируем сообщение о результатах
const successCount = response.results.filter(
(r: DeployResult) => r.success,
).length;
const failCount = response.results.length - successCount;
if (failCount === 0) {
setSubmitMessage(
`Успешно развернуто ${successCount} агент(ов) на ${agents.length} сервер(ах)`,
);
setAgents([createEmptyAgentConfig()]);
} else {
const errorMsg = response.results
.filter((r: DeployResult) => !r.success)
.map(
(r: DeployResult) => `${r.ip}: ${r.error || "Неизвестная ошибка"}`,
)
.join("\n");
setSubmitMessage(`Успешно: ${successCount}, Ошибки: ${failCount}`);
setSubmitError(`Ошибки при развертывании:\n${errorMsg}`);
}
} catch (error) {
setSubmitError("Ошибка при развертывании на серверах");
setSubmitError(
error instanceof Error
? error.message
: "Ошибка при развертывании агентов",
);
} finally {
setIsSubmitting(false);
}
@@ -162,20 +203,26 @@ export const AddAgentsPage: React.FC = () => {
color: "var(--success-text)",
}}
>
{submitMessage}
<div className="flex items-start gap-2">
<FiCheck className="mt-0.5 flex-shrink-0" size={16} />
<span>{submitMessage}</span>
</div>
</div>
)}
{submitError && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
className="mb-6 p-4 rounded-lg border text-sm whitespace-pre-wrap"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
{submitError}
<div className="flex items-start gap-2">
<FiAlertCircle className="mt-0.5 flex-shrink-0" size={16} />
<span>{submitError}</span>
</div>
</div>
)}
+5
View File
@@ -0,0 +1,5 @@
import { AdminPanel } from "@/modules/admin";
export const AdminPage = () => {
return <AdminPanel />;
};
+398
View File
@@ -0,0 +1,398 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import {
startMetricsPolling,
stopMetricsPolling,
} from "@/app/providers/layout/store/metrics.store";
import { useMetricsStore } from "@/app/providers/layout/store/metrics.store";
import { FiArrowLeft, FiCpu, FiHardDrive } from "react-icons/fi";
import { FaMemory, FaNetworkWired } from "react-icons/fa";
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
interface MetricsSnapshot {
timestamp: string;
metrics: Record<string, SystemMetrics>;
}
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;
}
export const AgentDashboardPage = () => {
const { agentLabel } = useParams<{ agentLabel: string }>();
const navigate = useNavigate();
const { metrics, lastUpdated } = useMetricsStore();
const [history, setHistory] = useState<MetricsSnapshot[]>([]);
useEffect(() => {
startMetricsPolling();
return () => stopMetricsPolling();
}, []);
const agentMetric = useMemo(
() => metrics.find((m) => m.label === agentLabel),
[metrics, agentLabel],
);
useEffect(() => {
if (metrics.length > 0) {
const now = new Date().toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
const map: Record<string, SystemMetrics> = {};
metrics.forEach((m) => {
map[m.label] = m;
});
setHistory((prev) =>
[...prev, { timestamp: now, metrics: map }].slice(-60),
);
}
}, [metrics]);
const historyData = useMemo(() => {
return history
.map((s) => {
const m = s.metrics[agentLabel || ""];
return m
? [
{ timestamp: s.timestamp, value: m.cpu_percent, metric: "CPU" },
{
timestamp: s.timestamp,
value: m.memory_percent,
metric: "RAM",
},
{ timestamp: s.timestamp, value: m.disk_percent, metric: "Disk" },
]
: [];
})
.flat();
}, [history, agentLabel]);
const cpuHistory = useMemo(
() => historyData.filter((d) => d.metric === "CPU"),
[historyData],
);
const ramHistory = useMemo(
() => historyData.filter((d) => d.metric === "RAM"),
[historyData],
);
const diskHistory = useMemo(
() => historyData.filter((d) => d.metric === "Disk"),
[historyData],
);
const displayMetric = agentMetric;
if (!displayMetric) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p style={{ color: "var(--text-muted)" }}>
Метрики для агента "{agentLabel}" не найдены
</p>
</div>
</div>
);
}
return (
<div style={{ padding: "16px 20px" }}>
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => navigate("/dashboard")}
style={{
background: "transparent",
border: "1px solid var(--border)",
color: "var(--text-primary)",
padding: "6px 10px",
borderRadius: "4px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<FiArrowLeft size={14} />
Назад
</button>
<div>
<h1
style={{
fontSize: "16px",
fontWeight: 600,
color: "var(--text-primary)",
margin: 0,
}}
>
{displayMetric.label}
</h1>
<p
style={{
fontSize: "12px",
color: "var(--text-secondary)",
margin: "2px 0 0 0",
}}
>
{lastUpdated && (
<span>
Обновлено {new Date(lastUpdated).toLocaleTimeString()}
</span>
)}
</p>
</div>
</div>
{/* Metric cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "12px",
marginBottom: "20px",
}}
>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FiCpu size={16} style={{ color: "#3b82f6" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
CPU
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.cpu_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FaMemory size={16} style={{ color: "#10b981" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
RAM
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.memory_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FiHardDrive size={16} style={{ color: "#f59e0b" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Disk
</span>
</div>
<div
style={{
fontSize: "24px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{displayMetric.disk_percent.toFixed(1)}%
</div>
</div>
<div
style={{
padding: "16px",
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "8px",
}}
>
<div className="flex items-center gap-2 mb-2">
<FaNetworkWired size={16} style={{ color: "#8b5cf6" }} />
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Network
</span>
</div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatBytes(displayMetric.network_rx_bytes)}
</div>
<div
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{formatBytes(displayMetric.network_tx_bytes)}
</div>
</div>
</div>
{/* Charts */}
<div
style={{
maxWidth: "1100px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{/* CPU History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
CPU Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cpuHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#3b82f6"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* RAM History */}
<div style={{ height: 280 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
}}
>
Memory Usage History (%)
</h3>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={ramHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="timestamp"
stroke="var(--text-secondary)"
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
stroke="var(--text-secondary)"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "12px",
}}
/>
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="#10b981"
label={{
position: "top",
fill: "var(--text-primary)",
fontSize: 10,
formatter: (v: number) => `${v.toFixed(0)}%`,
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
+197
View File
@@ -0,0 +1,197 @@
import { DashboardChart } from "@/modules/dashboard/components/dashboard.chart";
import {
ResponsiveContainer,
PieChart,
Pie,
Cell,
Tooltip,
Legend,
} from "recharts";
const generateTimeData = (count: number, base: number, variance: number) => {
const data = [];
const now = new Date();
for (let i = count - 1; i >= 0; i--) {
const time = new Date(now.getTime() - i * 60000);
const h = time.getHours().toString().padStart(2, "0");
const m = time.getMinutes().toString().padStart(2, "0");
data.push({
timestamp: `${h}:${m}`,
value: Math.round(
base + Math.sin(i / 3) * variance + Math.random() * variance * 0.5,
),
});
}
return data;
};
const cpuData = generateTimeData(20, 45, 25).map((d, i) => ({
timestamp: d.timestamp,
"Использование %": d.value,
}));
const ramData = generateTimeData(20, 60, 15).map((d) => ({
timestamp: d.timestamp,
"Использовано ГБ": d.value / 10,
"Свободно ГБ": 16 - d.value / 10,
}));
const diskData = generateTimeData(20, 70, 5).map((d) => ({
timestamp: d.timestamp,
"Занято ГБ": d.value,
}));
const networkData = generateTimeData(20, 50, 30).map((d) => ({
timestamp: d.timestamp,
"Входящий Мбит/с": d.value,
"Исходящий Мбит/с": Math.round(d.value * 0.4),
}));
const metricData = [
{ name: "INFO", value: 125 },
{ name: "WARN", value: 42 },
{ name: "ERROR", value: 18 },
{ name: "CRITICAL", value: 5 },
];
export const DashboardPage = () => {
return (
<div style={{ padding: "16px 20px" }}>
<h1
style={{
fontSize: "16px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "16px",
}}
>
Мониторинг системы
</h1>
<div
style={{
maxWidth: "1100px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{/* Центр: Метрика логов — круговая диаграмма */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div style={{ width: "100%", maxWidth: 600 }}>
<h3
style={{
fontSize: "13px",
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: "8px",
textAlign: "center",
}}
>
Метрики логов
</h3>
<div style={{ height: 320 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={metricData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={110}
paddingAngle={4}
dataKey="value"
nameKey="name"
strokeWidth={0}
>
{metricData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={
["#10b981", "#f59e0b", "#ef4444", "#dc2626"][index]
}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "var(--card-bg)",
border: "1px solid var(--border)",
borderRadius: "4px",
fontSize: "11px",
padding: "4px 8px",
}}
/>
<Legend
wrapperStyle={{
fontSize: "11px",
paddingTop: "4px",
}}
iconType="circle"
iconSize={8}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Верхний ряд: CPU + RAM */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "12px",
}}
>
<DashboardChart
title="CPU"
type="line"
data={cpuData}
dataKeys={["Использование %"]}
colors={["#3b82f6"]}
/>
<DashboardChart
title="Оперативная память"
type="area"
data={ramData}
dataKeys={["Использовано ГБ", "Свободно ГБ"]}
colors={["#10b981", "#64748b"]}
/>
</div>
{/* Нижний ряд: Диск + Сеть */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "12px",
}}
>
<DashboardChart
title="Жесткий диск"
type="line"
data={diskData}
dataKeys={["Занято ГБ"]}
colors={["#f59e0b"]}
/>
<DashboardChart
title="Сеть"
type="area"
data={networkData}
dataKeys={["Входящий Мбит/с", "Исходящий Мбит/с"]}
colors={["#8b5cf6", "#06b6d4"]}
/>
</div>
</div>
</div>
);
};
+150
View File
@@ -0,0 +1,150 @@
import { useState, useEffect, useMemo } from "react";
import {
Graph,
type GraphData,
type GraphNode,
type GraphLink,
} from "@/modules/graph";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import { FaSpinner } from "react-icons/fa";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
const buildGraphFromApi = (apiData: any, agents: any[]): GraphData => {
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
// 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) => {
// Parse "serviceName:up" or "serviceName:down"
const parts = svc.split(":");
const svcName = parts[0];
const status = parts[1] === "down" ? "down" : "up";
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
});
});
if (!apiData?.nodes) return { nodes, links };
Object.entries(apiData.nodes).forEach(
([agentLabel, agentNode]: [string, any]) => {
// Агент как узел
nodes.push({
id: agentLabel,
name: agentLabel,
type: "agent",
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",
val: 12,
description: `Сервис: ${serviceName}`,
status,
});
// Связь агент → сервис
links.push({
source: agentLabel,
target: serviceId,
type: "hosts",
});
// Зависимости между сервисами
const dependencies = serviceNode?.dependencies || [];
dependencies.forEach((dep: any) => {
const targetServiceName = dep?.target?.name;
if (targetServiceName) {
const targetServiceId = `${agentLabel}-${targetServiceName}`;
links.push({
source: serviceId,
target: targetServiceId,
type: dep.condition || "dependency",
});
}
});
},
);
},
);
return { nodes, links };
};
export const GraphsPage = () => {
const agents = useAgentStore((s) => s.agents);
const [graphData, setGraphData] = useState<GraphData>({
nodes: [],
links: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchGraph = async () => {
setLoading(true);
setError(null);
try {
const apiData = await agentApiService.getGraph();
const data = buildGraphFromApi(apiData, agents);
setGraphData(data);
} catch (e) {
console.error("Failed to fetch graph:", e);
setError(e instanceof Error ? e.message : "Failed to load graph");
setGraphData({ nodes: [], links: [] });
} finally {
setLoading(false);
}
};
fetchGraph();
const interval = setInterval(fetchGraph, 30000);
return () => clearInterval(interval);
}, [agents]);
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FaSpinner
className="animate-spin mx-auto mb-4"
size={32}
style={{ color: "var(--accent)" }}
/>
<p style={{ color: "var(--text-secondary)" }}>Загрузка графа...</p>
</div>
</div>
);
}
if (error && graphData.nodes.length === 0) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p style={{ color: "var(--error-text)", marginBottom: "12px" }}>
{error}
</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Graph initialData={graphData} />
</div>
);
};
+18
View File
@@ -0,0 +1,18 @@
import { useLocation, useNavigate } from "react-router-dom";
import { IDE } from "../modules/ide";
import type { FileNode } from "../modules/ide";
export const IDEPage = () => {
const navigate = useNavigate();
const location = useLocation();
const files: FileNode | undefined = location.state?.files;
return (
<div
className="absolute top-0 left-0 w-full h-full z-90"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<IDE onBack={() => navigate("/templates")} initialFiles={files} />
</div>
);
};
+422
View File
@@ -0,0 +1,422 @@
import React, { useState, useEffect, useCallback } from "react";
import { agentApiService } from "@/modules/agent";
import type { LogEntry } from "@/modules/agent";
import { LogFilters } from "@/modules/agent/ui/LogFilters";
import { useLogFilterStore } from "@/modules/agent/store/logFilter.store";
import {
FiFileText,
FiRefreshCw,
FiChevronLeft,
FiChevronRight,
FiInfo,
FiAlertTriangle,
FiAlertCircle,
FiXOctagon,
} from "react-icons/fi";
const logLevelIcons: Record<string, React.ReactNode> = {
info: <FiInfo size={14} />,
warning: <FiAlertTriangle size={14} />,
error: <FiAlertCircle size={14} />,
fatal: <FiXOctagon size={14} />,
};
const logLevelColors: Record<
string,
{ 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)",
},
};
export const LogsPage: React.FC = () => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [availableServices, setAvailableServices] = useState<string[]>([]);
const [availableAgents, setAvailableAgents] = useState<string[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const { getFilters, limit, offset, setOffset } = useLogFilterStore();
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const filters = getFilters();
const data = await agentApiService.searchLogs(filters);
if (!Array.isArray(data)) {
console.error("[Logs] Expected array, got:", typeof data);
setLogs([]);
setTotalLogs(0);
return;
}
setLogs(data);
setTotalLogs(data.length);
} catch (err) {
setError(
err instanceof Error ? err.message : "Ошибка при загрузке логов",
);
setLogs([]);
setTotalLogs(0);
} finally {
setIsLoading(false);
}
}, [getFilters]);
const fetchDistinctData = useCallback(async () => {
try {
const [servicesResult, agentsResult] = await Promise.allSettled([
agentApiService.getDistinctServices(),
agentApiService.getDistinctAgents(),
]);
if (
servicesResult.status === "fulfilled" &&
Array.isArray(servicesResult.value)
) {
setAvailableServices(servicesResult.value);
} else {
console.error(
"[Logs] Failed to fetch services:",
servicesResult.status === "rejected"
? servicesResult.reason
: "non-array response",
);
setAvailableServices([]);
}
if (
agentsResult.status === "fulfilled" &&
Array.isArray(agentsResult.value)
) {
setAvailableAgents(agentsResult.value);
} else {
console.error(
"[Logs] Failed to fetch agents:",
agentsResult.status === "rejected"
? agentsResult.reason
: "non-array response",
);
setAvailableAgents([]);
}
} catch (err) {
console.error("[Logs] Failed to fetch distinct data:", err);
setAvailableServices([]);
setAvailableAgents([]);
}
}, []);
useEffect(() => {
fetchDistinctData();
}, [fetchDistinctData]);
useEffect(() => {
fetchLogs();
}, [fetchLogs, offset, limit]);
const handleFilterApply = () => {
setOffset(0);
fetchLogs();
};
const handleNextPage = () => {
setOffset(offset + limit);
};
const handlePrevPage = () => {
setOffset(Math.max(0, offset - limit));
};
const formatTimestamp = (timestamp: string | undefined | null) => {
if (!timestamp) return "—";
const date = new Date(timestamp);
if (isNaN(date.getTime())) return "—";
return date.toLocaleString("ru-RU", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
return (
<div
className="min-h-screen py-8 px-4"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiFileText
className="w-7 h-7"
style={{ color: "var(--accent)" }}
/>
</div>
<div>
<h1
className="text-3xl font-bold mb-1"
style={{ color: "var(--text-primary)" }}
>
Поиск логов
</h1>
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
Фильтрация и анализ логов системы
</p>
</div>
</div>
</div>
{/* Filters */}
<div className="mb-6">
<LogFilters
onApply={handleFilterApply}
availableServices={availableServices}
availableAgents={availableAgents}
/>
</div>
{/* Error Message */}
{error && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
{error}
</div>
)}
{/* Logs Table */}
<div
className="rounded-xl border overflow-hidden"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
{/* Table Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<span
className="text-sm font-medium"
style={{ color: "var(--text-primary)" }}
>
Найдено: {totalLogs} записей
</span>
<button
onClick={fetchLogs}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium border"
style={{
backgroundColor: "transparent",
color: "var(--accent)",
borderColor: "var(--border)",
}}
>
<FiRefreshCw
size={12}
className={isLoading ? "animate-spin" : ""}
/>
Обновить
</button>
</div>
{isLoading ? (
<div
className="flex items-center justify-center py-12"
style={{ color: "var(--text-secondary)" }}
>
<FiRefreshCw size={24} className="animate-spin mr-3" />
Загрузка логов...
</div>
) : logs.length === 0 ? (
<div
className="text-center py-12"
style={{ color: "var(--text-muted)" }}
>
Логи не найдены
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--text-secondary)" }}
>
Время
</th>
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--text-secondary)" }}
>
Уровень
</th>
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--text-secondary)" }}
>
Сервис
</th>
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--text-secondary)" }}
>
Агент
</th>
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--text-secondary)" }}
>
Сообщение
</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => {
const level = log.Level?.toLowerCase() || "info";
const colors =
logLevelColors[level] || logLevelColors.info;
return (
<tr
key={index}
className="border-t transition-colors"
style={{
borderColor: "var(--border)",
backgroundColor:
index % 2 === 0
? "var(--card-bg)"
: "var(--bg-secondary)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
index % 2 === 0
? "var(--card-bg)"
: "var(--bg-secondary)";
}}
>
<td
className="px-4 py-3 text-sm font-mono whitespace-nowrap"
style={{ color: "var(--text-secondary)" }}
>
{formatTimestamp(log.Timestamp)}
</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderColor: colors.border,
}}
>
{logLevelIcons[level]}
{level}
</span>
</td>
<td
className="px-4 py-3 text-sm"
style={{ color: "var(--text-primary)" }}
>
{log.Service || "—"}
</td>
<td
className="px-4 py-3 text-sm font-mono"
style={{ color: "var(--text-primary)" }}
>
{log.Agent || "—"}
</td>
<td
className="px-4 py-3 text-sm"
style={{ color: "var(--text-primary)" }}
>
{log.Message || "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div
className="flex items-center justify-between px-4 py-3 border-t"
style={{ borderColor: "var(--border)" }}
>
<button
onClick={handlePrevPage}
disabled={offset === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
borderColor: "var(--border)",
}}
>
<FiChevronLeft size={16} />
Назад
</button>
<span
className="text-sm"
style={{ color: "var(--text-secondary)" }}
>
Показано {logs.length} записей (смещение: {offset})
</span>
<button
onClick={handleNextPage}
disabled={logs.length < limit}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
borderColor: "var(--border)",
}}
>
Далее
<FiChevronRight size={16} />
</button>
</div>
</>
)}
</div>
</div>
</div>
);
};
+43 -12
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from "@/modules/auth/store/useAuthStore";
export const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { register, isLoading, error, clearError, token } = useAuthStore();
const { register, isLoading, error, clearError } = useAuthStore();
const [formData, setFormData] = useState({
login: "",
password: "",
@@ -14,12 +14,7 @@ export const RegisterPage: React.FC = () => {
lastName: "",
});
const [passwordError, setPasswordError] = useState<string | null>(null);
useEffect(() => {
if (token) {
navigate("/");
}
}, [token, navigate]);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -38,7 +33,17 @@ export const RegisterPage: React.FC = () => {
firstName: formData.firstName,
lastName: formData.lastName,
});
navigate("/");
setSuccessMessage("Аккаунт успешно создан! Теперь вы можете войти.");
setFormData({
login: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
setTimeout(() => {
navigate("/auth");
}, 2000);
} catch (err) {
// Error is handled by store
}
@@ -82,7 +87,10 @@ export const RegisterPage: React.FC = () => {
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiUserPlus className="w-8 h-8" style={{ color: "var(--accent)" }} />
<FiUserPlus
className="w-8 h-8"
style={{ color: "var(--accent)" }}
/>
</div>
<h1
className="text-3xl font-bold mb-2"
@@ -109,6 +117,20 @@ export const RegisterPage: React.FC = () => {
</div>
)}
{/* Success Message */}
{successMessage && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--success-bg)",
borderColor: "var(--success-border)",
color: "var(--success-text)",
}}
>
{successMessage}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name Fields */}
@@ -293,8 +315,16 @@ export const RegisterPage: React.FC = () => {
className="mt-2 text-sm flex items-center gap-1"
style={{ color: "var(--error-text)" }}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
{passwordError}
</p>
@@ -311,7 +341,8 @@ export const RegisterPage: React.FC = () => {
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)";
e.currentTarget.style.backgroundColor =
"var(--button-primary-hover)";
}
}}
onMouseLeave={(e) => {
+423
View File
@@ -0,0 +1,423 @@
import React, { useState } from "react";
import { agentApiService } from "@/modules/agent/api/agent.api.service";
import { FiKey, FiPlus, FiTrash2, FiCopy, FiCheck, FiX } from "react-icons/fi";
interface RegistrationTokenForm {
label: string;
}
interface RegistrationResult {
label: string;
token: string;
}
export const RegistrationTokenPage: React.FC = () => {
const [tokens, setTokens] = useState<RegistrationTokenForm[]>([
{ label: "" },
]);
const [results, setResults] = useState<RegistrationResult[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const handleTokenChange = (index: number, label: string) => {
const newTokens = [...tokens];
newTokens[index] = { label };
setTokens(newTokens);
};
const handleAddToken = () => {
setTokens([...tokens, { label: "" }]);
};
const handleRemoveToken = (index: number) => {
const newTokens = tokens.filter((_, i) => i !== index);
setTokens(newTokens);
};
const handleCopyToken = async (token: string, index: number) => {
await navigator.clipboard.writeText(token);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Валидация
const validTokens = tokens.filter((t) => t.label.trim());
if (validTokens.length === 0) {
setError("Введите хотя бы одну метку для токена");
return;
}
setIsSubmitting(true);
setError(null);
setSuccessMessage(null);
setResults([]);
try {
const createdTokens: RegistrationResult[] = [];
for (const tokenData of validTokens) {
const response = await agentApiService.createRegistrationToken({
label: tokenData.label,
});
// API возвращает объект с токеном
const token = response.token || Object.values(response)[0] as string;
createdTokens.push({
label: tokenData.label,
token,
});
}
setResults(createdTokens);
setSuccessMessage(
`Успешно создано ${createdTokens.length} токен(ов)`
);
setTokens([{ label: "" }]);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Ошибка при создании токенов"
);
} finally {
setIsSubmitting(false);
}
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border)",
borderRadius: "8px",
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
fontSize: "14px",
transition: "border-color 0.2s, box-shadow 0.2s",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "8px",
color: "var(--text-secondary)",
fontSize: "14px",
fontWeight: 500,
};
return (
<div
className="min-h-screen py-8 px-4"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div style={{ maxWidth: "900px", margin: "0 auto" }}>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiKey className="w-7 h-7" style={{ color: "var(--accent)" }} />
</div>
<div>
<h1
className="text-3xl font-bold mb-1"
style={{ color: "var(--text-primary)" }}
>
Регистрация токенов для агентов
</h1>
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
Создайте токены для регистрации новых агентов
</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit}>
{/* Token Forms */}
<div className="space-y-5">
{tokens.map((token, index) => (
<div
key={index}
className="rounded-2xl shadow-lg border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
padding: "24px",
marginBottom: "20px",
}}
>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
paddingBottom: "16px",
borderBottom: "1px solid var(--border)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiKey style={{ color: "var(--accent)", fontSize: "20px" }} />
</div>
<h3
style={{
margin: 0,
color: "var(--text-primary)",
fontSize: "18px",
fontWeight: 600,
}}
>
Токен #{index + 1}
</h3>
</div>
{tokens.length > 1 && (
<button
type="button"
onClick={() => handleRemoveToken(index)}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all"
style={{
background: "var(--error-bg)",
color: "var(--error-text)",
border: "1px solid var(--error-border)",
cursor: "pointer",
fontSize: "14px",
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--error-text)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--error-bg)";
e.currentTarget.style.color = "var(--error-text)";
}}
>
<FiTrash2 size={14} />
Удалить
</button>
)}
</div>
{/* Label Input */}
<div>
<label style={labelStyle}>
<span
style={{ display: "flex", alignItems: "center", gap: "6px" }}
>
<FiKey size={14} />
Метка токена *
</span>
</label>
<input
type="text"
value={token.label}
onChange={(e) => handleTokenChange(index, e.target.value)}
required
style={inputStyle}
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";
}}
placeholder="agent-production-1"
/>
</div>
</div>
))}
</div>
{/* Add Token Button */}
<button
type="button"
onClick={handleAddToken}
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl border-2 border-dashed transition-all mb-6 font-medium"
style={{
borderColor: "var(--border)",
backgroundColor: "transparent",
color: "var(--accent)",
fontSize: "15px",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.backgroundColor = "var(--accent)10";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<FiPlus size={18} />
Добавить ещё один токен
</button>
{/* Messages */}
{successMessage && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--success-bg)",
borderColor: "var(--success-border)",
color: "var(--success-text)",
}}
>
<div className="flex items-center justify-between">
<span>{successMessage}</span>
<button
onClick={() => setSuccessMessage(null)}
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
>
<FiX size={16} />
</button>
</div>
</div>
)}
{error && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
<div className="flex items-center justify-between">
<span>{error}</span>
<button
onClick={() => setError(null)}
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
>
<FiX size={16} />
</button>
</div>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-base mb-8"
style={{
backgroundColor: isSubmitting
? "var(--bg-secondary)"
: "var(--button-primary)",
color: "var(--button-primary-text)",
boxShadow: isSubmitting
? "none"
: "0 4px 14px var(--shadow-color)",
}}
onMouseEnter={(e) => {
if (!isSubmitting) {
e.currentTarget.style.backgroundColor =
"var(--button-primary-hover)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = isSubmitting
? "var(--bg-secondary)"
: "var(--button-primary)";
}}
>
{isSubmitting ? (
<>
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
Создание токенов...
</>
) : (
<>
<FiKey size={18} />
Создать {tokens.filter((t) => t.label.trim()).length || 1} токен(ов)
</>
)}
</button>
{/* Results */}
{results.length > 0 && (
<div>
<h2
className="text-xl font-bold mb-4"
style={{ color: "var(--text-primary)" }}
>
Созданные токены
</h2>
<div className="space-y-4">
{results.map((result, index) => (
<div
key={index}
className="rounded-xl border p-4"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="flex items-center justify-between mb-3">
<span
className="font-medium"
style={{ color: "var(--text-primary)" }}
>
{result.label}
</span>
<button
type="button"
onClick={() => handleCopyToken(result.token, index)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium"
style={{
backgroundColor:
copiedIndex === index
? "var(--success-text)"
: "var(--accent)",
color:
copiedIndex === index
? "#fff"
: "var(--accent-text)",
}}
>
{copiedIndex === index ? (
<>
<FiCheck size={12} />
Скопировано
</>
) : (
<>
<FiCopy size={12} />
Копировать
</>
)}
</button>
</div>
<code
className="block p-3 rounded-lg text-xs font-mono break-all"
style={{
backgroundColor: "var(--bg-secondary)",
color: "var(--accent)",
border: "1px solid var(--border)",
}}
>
{result.token}
</code>
</div>
))}
</div>
</div>
)}
</form>
</div>
</div>
);
};
+230
View File
@@ -0,0 +1,230 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { FiEdit3 } from "react-icons/fi";
import { MdAdd } from "react-icons/md";
import { FaSpinner } from "react-icons/fa";
import { FilePicker } from "../modules/ide";
import { RunScriptModal } from "../modules/ide/components/RunScriptModal";
import { AddInterpreterModal } from "../modules/ide/components/AddInterpreterModal";
import type { FileNode } from "../modules/ide";
import { scriptsApi } from "../modules/ide/api/scripts.api";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
const convertTreeToFileNode = (data: any[]): FileNode => {
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;
};
return {
name: "templates",
type: "folder",
children: data.map((item) => convertItem(item)),
};
};
export const TemplatesPage = () => {
const navigate = useNavigate();
const { user } = useAuthStore();
const canManageAgent = user?.permission_manage_agent;
const [files, setFiles] = useState<FileNode | null>(null);
const [loading, setLoading] = useState(true);
const [runModal, setRunModal] = useState<{
scriptPath: string;
scriptId: number;
} | null>(null);
const [showAddInterpreter, setShowAddInterpreter] = useState(false);
const reloadTree = () => {
setLoading(true);
scriptsApi
.getTree()
.then((data) => {
setFiles(convertTreeToFileNode(data));
})
.catch((e) => {
console.error("Failed to load tree:", e);
setFiles({ name: "templates", type: "folder", children: [] });
})
.finally(() => setLoading(false));
};
useEffect(() => {
reloadTree();
}, []);
const handleRun = (path: string, id?: number) => {
if (!id) {
console.warn("Script ID not found for:", path);
return;
}
setRunModal({ scriptPath: path, scriptId: id });
};
return (
<div
style={{
height: "100vh",
backgroundColor: "var(--bg-primary)",
display: "flex",
flexDirection: "column",
}}
>
{/* Header bar */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "12px 16px",
gap: "12px",
borderBottom: "1px solid var(--border)",
backgroundColor: "var(--card-bg)",
flexShrink: 0,
}}
>
{/* Add Interpreter button */}
<button
onClick={() => setShowAddInterpreter(true)}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 14px",
backgroundColor: "#238636",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#2ea043";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#238636";
}}
>
<MdAdd size={14} />
Add Interpreter
</button>
{/* Open in Editor button — только с правом manage_agent */}
{canManageAgent && (
<button
onClick={() => navigate("/ide")}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 16px",
backgroundColor: "#0e639c",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#1177bb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#0e639c";
}}
>
<FiEdit3 size={14} />
Open Editor
</button>
)}
</div>
{/* File Picker (terminal встроен внутрь) */}
<div style={{ flex: 1, overflow: "hidden" }}>
{loading ? (
<div
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FaSpinner
size={24}
style={{
color: "var(--accent)",
animation: "spin 1s linear infinite",
}}
/>
</div>
) : files ? (
<FilePicker
files={files}
onRun={(path) => {
// Находим ID скрипта по пути
const findNodeById = (
node: FileNode,
p: string,
): FileNode | null => {
if (node.path === p) return node;
if (node.children) {
for (const child of node.children) {
const found = findNodeById(child, p);
if (found) return found;
}
}
return null;
};
const node = findNodeById(files, path);
if (node?.id) {
handleRun(path, node.id);
} else {
console.warn("Script ID not found for path:", path);
}
}}
/>
) : null}
</div>
{/* Run Script Modal */}
{runModal && (
<RunScriptModal
scriptPath={runModal.scriptPath}
scriptId={runModal.scriptId}
onClose={() => setRunModal(null)}
/>
)}
{/* Add Interpreter Modal */}
{showAddInterpreter && (
<AddInterpreterModal
onClose={() => setShowAddInterpreter(false)}
onSuccess={reloadTree}
/>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
import { ThemeChanger } from "@/modules/theme-changer";
export const ThemesPage = () => {
return (
<div>
<ThemeChanger label="Выбор тем приложения" />
</div>
);
};
+29 -2
View File
@@ -16,13 +16,40 @@ class ApiClient {
constructor() {
this.axiosInstance = axios.create({
baseURL: "http://194.113.106.59:8080/api/v1",
baseURL: "http://213.165.213.170:8080/api/v1",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
validateStatus: (status) => {
return status >= 200 && status < 500;
return status >= 200 && status < 400;
},
// Добавляем кастомный сериализатор параметров
paramsSerializer: {
serialize: (params) => {
const parts: string[] = [];
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (Array.isArray(value)) {
// Преобразуем массив в множественные параметры: level=info&level=warning
value.forEach((item) => {
if (item !== undefined && item !== null) {
parts.push(
`${encodeURIComponent(key)}=${encodeURIComponent(item)}`,
);
}
});
} else {
parts.push(
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
);
}
});
return parts.join("&");
},
},
});
+5 -5
View File
@@ -1,5 +1,5 @@
// shared/api/websocket.service.ts
import { useAgentStore } from "@/components/layout/sidebar/store/agent.store";
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
import { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket";
import { useEffect, useRef, useCallback, useMemo } from "react";
@@ -25,12 +25,12 @@ export const useWebSocketService = ({
const selectedServices: string[] = [];
const selectedHosts: string[] = [];
// TODO: реализовать механизм выбора сервисов
// Пока выбираем все
agents.forEach((agent) => {
agent.services.forEach((service) => {
if (service.isSelected) {
selectedServices.push(service.name);
selectedHosts.push(agent.token);
}
selectedServices.push(service);
selectedHosts.push(agent.token);
});
});
+10 -83
View File
@@ -1,90 +1,17 @@
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
import { ThemeToggle } from "@/modules/theme-bw/ui/ThemeToggle";
import React from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { Navigate, Outlet } from "react-router-dom";
import { Layout } from "@/app/providers/layout/layout";
interface DefaultLayoutProps {
children?: React.ReactNode;
}
export const DefaultLayout = () => {
const { token } = useAuthStore();
export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate("/auth");
};
// if (!token) {
// return <Navigate to="/auth" replace />;
// }
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
{/* Header */}
<header
className="border-b sticky top-0 z-50"
style={{
backgroundColor: "var(--header-bg)",
borderColor: "var(--border)",
}}
>
<div className="container mx-auto px-4 py-3">
<div className="flex justify-between items-center">
{/* Logo */}
<div
className="text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity"
style={{ color: "var(--text-primary)" }}
onClick={() => navigate("/")}
>
HellreigN
</div>
{/* Right side */}
<div className="flex items-center gap-3">
<ThemeToggle />
{user && (
<div className="flex items-center gap-3">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{user.firstName} {user.lastName}
</span>
<button
onClick={handleLogout}
className="px-3 py-1.5 text-sm rounded-lg transition-colors font-medium"
style={{
backgroundColor: "var(--button-danger)",
color: "var(--button-danger-text)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-danger-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--button-danger)";
}}
>
Выйти
</button>
</div>
)}
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="flex-1">{children || <Outlet />}</main>
{/* Footer */}
<footer
className="border-t py-4 mt-auto"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<div className="container mx-auto px-4">
<p className="text-center text-sm" style={{ color: "var(--text-muted)" }}>
© 2026 HellreigN. Все права защищены.
</p>
</div>
</footer>
</div>
<Layout>
<Outlet />
</Layout>
);
};
+10
View File
@@ -2,3 +2,13 @@
@import "./normalize.css";
@import "./root.css";
@import "./themes.css";
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
+1332 -510
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -6,3 +6,8 @@ cert_dir: /etc/hellreign-agent/certs
services:
- name: system
type: journald
- name: nginx
type: docker
- name: sshd
type: journald
systemd_unit: sshd
+2
View File
@@ -29,6 +29,7 @@ services:
container_name: hellreign-backend
environment:
CONFIG_FILE: /etc/hellreign/config.yml
GRAPH_YAML_PATH: /etc/hellreign/services.yaml
SSL_CERT_DIR: /var/lib/hellreign/ssl
SERVER_SAN_DNS: localhost,backend
SERVER_SAN_IP: 127.0.0.1
@@ -37,6 +38,7 @@ services:
- "9001:9001"
volumes:
- ./backend/config.yml:/etc/hellreign/config.yml:ro
- ./services.yaml:/etc/hellreign/services.yaml:ro
- backend_data:/var/lib/hellreign
depends_on:
clickhouse:
+11
View File
@@ -0,0 +1,11 @@
nodes:
test-agent-1:
services:
nginx:
depends_on:
- sshd
sshd:
depends_on: []
system:
depends_on:
- sshd