Compare commits
64 Commits
2714bd1178
...
backend
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2d41d04e | |||
| a70791898c | |||
| 26323dfd15 | |||
| 7d2f3d0f3a | |||
| 255fe2eaf3 | |||
| 6d6dd91241 | |||
| 915aa7018a | |||
| eb8aef11a4 | |||
| 4a00c95d25 | |||
| e9fdaf8711 | |||
| 413e31c711 | |||
| c175461634 | |||
| f26fa3da69 | |||
| 5b90447984 | |||
| 247505a310 | |||
| ad9d567d2c | |||
| c6c46aee68 | |||
| 9f6defd25c | |||
| 5f6c4303db | |||
| 17d4770de6 | |||
| 337e5891f3 | |||
| 2bc3da21fd | |||
| d6512d6c97 | |||
| f14490c076 | |||
| 178c3b53f7 | |||
| 5073cfd357 | |||
| f71a3b1a03 | |||
| e024f91111 | |||
| 8f5558fdb7 | |||
| 07066ec8c0 | |||
| 31eecf4ba5 | |||
| cf6065b55a | |||
| 43ea41f633 | |||
| 6b82c99d50 | |||
| c73035019f | |||
| e3fae7a02c | |||
| d46d0f8253 | |||
| bcca8fa298 | |||
| 400ceab47c | |||
| c6a9907822 | |||
| 69ff617c30 | |||
| 3430070df8 | |||
| 78f35f6811 | |||
| 55cb214458 | |||
| 8175d7b3a5 | |||
| 822f953698 | |||
| e7f1ea2386 | |||
| aac3fa3758 | |||
| 26ca7c0d51 | |||
| dd921e5892 | |||
| eedc9c9b62 | |||
| 4f69e002c6 | |||
| 5209e8b2e9 | |||
| 95a6902dae | |||
| adbb0ee368 | |||
| 96f82b4162 | |||
| ed439656f8 | |||
| d62205b329 | |||
| 11cef95929 | |||
| 43e16b1360 | |||
| f537f1eab9 | |||
| 9d1096a9b4 | |||
| 57b43da2e3 | |||
| 691e1fced5 |
@@ -0,0 +1,32 @@
|
|||||||
|
name: release-agent
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Go setup
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26.1"
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
workdir: agent
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -1 +1,358 @@
|
|||||||
# HellreigN
|
# HellreigN
|
||||||
|
|
||||||
|
Агент внутренней диагностики инфраструктуры. Централизованный сбор логов, мониторинг нагрузки, управление скриптами и контроль состояния сервисов.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Сбор логов** — journald, Docker, Kubernetes, файлы
|
||||||
|
- **Метрики нагрузки** — CPU, RAM, диск, сеть в реальном времени
|
||||||
|
- **Контроль сервисов** — проверка alive/dead для systemd и Docker
|
||||||
|
- **Удалённое выполнение команд** — запуск скриптов и команд на агентах
|
||||||
|
- **Граф зависимостей** — определение причин сбоев, порядок запуска
|
||||||
|
- **Офлайн-буфер** — логи не теряются при потере связи
|
||||||
|
- **mTLS** — защищённое соединение между агентом и бэкендом
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Инфраструктура │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ Agent 1 │ │ Agent 2 │ │ Agent N │ │
|
||||||
|
│ │ (хост) │ │ (docker) │ │ (k8s) │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┼──────────────┘ │
|
||||||
|
│ │ gRPC (mTLS) │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ Backend │ ◄── REST API │
|
||||||
|
│ │ :8080 / :9001│ │
|
||||||
|
│ └───────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌───────────────┐ │
|
||||||
|
│ │ ClickHouse │ ◄── Хранилище логов │
|
||||||
|
│ └───────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ HTTP
|
||||||
|
┌───────┴───────┐
|
||||||
|
│ Frontend │
|
||||||
|
│ :3000 │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Go 1.26.1+ (для локальной разработки)
|
||||||
|
|
||||||
|
### Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Поднимутся:
|
||||||
|
- **ClickHouse** — хранилище логов
|
||||||
|
- **Backend** — API (`8080`) + gRPC (`9001`)
|
||||||
|
- **Frontend** — веб-интерфейс (`3000`)
|
||||||
|
- **Agent** — пример агента
|
||||||
|
|
||||||
|
Откройте `http://localhost:3000`. Логин: `admin`, пароль: `admin123`.
|
||||||
|
Что бы агент заработал нужно в веб интрфейсе найти кнопку создать токен получить его и вписать в конфигурацию агента
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && go run ./cmd/main.go
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Agent
|
||||||
|
cd agent && CONFIG_FILE=./config.yml go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
`infra/backend/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
token_db: /var/lib/hellreign/tokens.db
|
||||||
|
clickhouse_host: clickhouse:9000
|
||||||
|
clickhouse_user: default
|
||||||
|
clickhouse_password: testpassword
|
||||||
|
clickhouse_database: hellreign
|
||||||
|
admin:
|
||||||
|
admin_name: Admin
|
||||||
|
admin_last_name: User
|
||||||
|
admin_login: admin
|
||||||
|
admin_password: admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агент
|
||||||
|
|
||||||
|
`infra/agent/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend_url: http://backend:8080
|
||||||
|
grpc_url: backend:9001
|
||||||
|
label: production-server-1
|
||||||
|
registration_token: "token-из-ui"
|
||||||
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
services:
|
||||||
|
# journald + проверка systemd
|
||||||
|
- name: nginx
|
||||||
|
type: journald
|
||||||
|
systemd_unit: nginx.service
|
||||||
|
|
||||||
|
# Docker контейнер
|
||||||
|
- name: redis
|
||||||
|
type: docker
|
||||||
|
|
||||||
|
# Файл
|
||||||
|
- name: myapp
|
||||||
|
type: file
|
||||||
|
path: /var/log/myapp/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле `systemd_unit` опционально. Если указано — агент проверяет `systemctl is-active` и шлёт статус `up`/`down`. Для Docker — `docker inspect {{.State.Running}}`.
|
||||||
|
|
||||||
|
### Граф зависимостей
|
||||||
|
|
||||||
|
`infra/services.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nodes:
|
||||||
|
production-server-1:
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
depends_on: [sshd]
|
||||||
|
sshd:
|
||||||
|
depends_on: []
|
||||||
|
```
|
||||||
|
|
||||||
|
Используется для определения причины сбоев и порядка запуска.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `CONFIG_FILE` | `/etc/hellreign/config.yml` | Путь к YAML конфигу |
|
||||||
|
| `GRAPH_YAML_PATH` | `/etc/hellreign/services.yaml` | Путь к графу сервисов |
|
||||||
|
| `SSL_CERT_DIR` | `/var/lib/hellreign/ssl` | Директория mTLS сертификатов |
|
||||||
|
| `SERVER_SAN_DNS` | `localhost,backend` | SAN DNS сертификата |
|
||||||
|
| `SERVER_SAN_IP` | `127.0.0.1` | SAN IP сертификата |
|
||||||
|
| `GRPC_PORT` | `9001` | Порт gRPC |
|
||||||
|
| `GIN_MODE` | `release` | Режим Gin |
|
||||||
|
|
||||||
|
### Агент
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|------------|-------------|----------|
|
||||||
|
| `CONFIG_FILE` | `/etc/hellreign-agent/config.yml` | Путь к YAML конфигу |
|
||||||
|
| `JOURNALD_LOGDIR` | `/var/log/journal` | Директория journald (ro) |
|
||||||
|
| `BUFFER_DB` | `/var/lib/hellreign-agent/agent_buffer.db` | SQLite буфер |
|
||||||
|
| `IS_DEBUG` | `0` | Debug логи (`1`/`0`) |
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
| Сервис | Порт | Назначение |
|
||||||
|
|--------|------|------------|
|
||||||
|
| Frontend | `3000` | Веб-интерфейс |
|
||||||
|
| Backend HTTP | `8080` | REST API + Swagger |
|
||||||
|
| Backend gRPC | `9001` | gRPC (mTLS) |
|
||||||
|
| ClickHouse HTTP | `8123` | HTTP интерфейс |
|
||||||
|
| ClickHouse Native | `9000` | Native протокол |
|
||||||
|
|
||||||
|
## API примеры
|
||||||
|
|
||||||
|
### Авторизация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"login":"admin","password":"admin123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Агенты и метрики
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Список подключённых агентов
|
||||||
|
curl http://localhost:8080/api/v1/agents \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
|
||||||
|
# Метрики нагрузки (CPU, RAM, disk, network)
|
||||||
|
curl http://localhost:8080/api/v1/agents/system-metrics \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Поиск
|
||||||
|
curl "http://localhost:8080/api/v1/logs?service=nginx&level=error" \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
|
||||||
|
# Список сервисов
|
||||||
|
curl http://localhost:8080/api/v1/logs/services \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Скрипты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Дерево
|
||||||
|
curl http://localhost:8080/api/v1/scripts/tree \
|
||||||
|
-H "Authorization: Bearer <jwt>"
|
||||||
|
|
||||||
|
# Запуск на агенте
|
||||||
|
curl -X POST http://localhost:8080/api/v1/scripts/1/run \
|
||||||
|
-H "Authorization: Bearer <jwt>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"token":"agent-token"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Swagger
|
||||||
|
|
||||||
|
`http://localhost:8080/swagger/index.html`
|
||||||
|
|
||||||
|
Перегенерация:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && swag init -g ./cmd/main.go --parseDependency --parseInternal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой агента на хост
|
||||||
|
|
||||||
|
### 1. Директории
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/hellreign-agent/certs /var/lib/hellreign-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Конфиг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/hellreign-agent/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend_url: https://monitoring.example.com
|
||||||
|
grpc_url: monitoring.example.com:9001
|
||||||
|
label: prod-web-1
|
||||||
|
registration_token: "token-из-ui"
|
||||||
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
services:
|
||||||
|
- name: nginx
|
||||||
|
type: journald
|
||||||
|
systemd_unit: nginx.service
|
||||||
|
- name: postgres
|
||||||
|
type: journald
|
||||||
|
systemd_unit: postgresql.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Бинарь
|
||||||
|
|
||||||
|
Скачать из релиза Gitea или собрать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent && go build -o hellreign-agent ./main.go
|
||||||
|
sudo mv hellreign-agent /usr/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Systemd
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=HellreigN Agent
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/hellreign-agent
|
||||||
|
Environment=CONFIG_FILE=/etc/hellreign-agent/config.yml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now hellreign-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
При пуше тега `v*` — GoReleaser собирает `.deb` и `.rpm` для `linux/amd64` и `linux/arm64`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Требует секрет `GITEA_TOKEN` в настройках репозитория.
|
||||||
|
|
||||||
|
## Proto
|
||||||
|
|
||||||
|
После изменений в `proto/hellreign.proto`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd proto
|
||||||
|
protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
|
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
hellreign.proto
|
||||||
|
mv hellreign*.go proto/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
HellreigN/
|
||||||
|
├── agent/ # Агент диагностики
|
||||||
|
│ ├── main.go
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── buffer/ # SQLite буфер (офлайн-доставка)
|
||||||
|
│ ├── client/ # gRPC клиент команд
|
||||||
|
│ ├── commander/ # Исполнитель команд
|
||||||
|
│ ├── config/ # YAML конфиг
|
||||||
|
│ ├── metrics/ # Сбор CPU, RAM, disk, network
|
||||||
|
│ ├── logsource/ # Источники логов
|
||||||
|
│ │ ├── docker/
|
||||||
|
│ │ ├── file/
|
||||||
|
│ │ ├── journald/
|
||||||
|
│ │ └── kubernetes/
|
||||||
|
│ ├── mtls/ # mTLS credentials
|
||||||
|
│ └── registration/ # Регистрация
|
||||||
|
├── backend/ # Бэкенд API
|
||||||
|
│ ├── cmd/main.go
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── handlers/ # HTTP хендлеры
|
||||||
|
│ ├── repository/ # SQLite репозитории
|
||||||
|
│ ├── grpcsrv/
|
||||||
|
│ │ ├── commander/ # Выполнение команд
|
||||||
|
│ │ └── collector/ # Сбор логов и метрик
|
||||||
|
│ ├── auth/ # JWT
|
||||||
|
│ └── storage/ # ClickHouse
|
||||||
|
├── frontend/ # React + TypeScript
|
||||||
|
├── infra/ # Docker Compose
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── services.yaml # Граф зависимостей
|
||||||
|
│ ├── backend/config.yml
|
||||||
|
│ ├── agent/config.yml
|
||||||
|
│ └── clickhouse/init/
|
||||||
|
├── migrations/ # SQL миграции SQLite
|
||||||
|
└── proto/ # Protobuf
|
||||||
|
```
|
||||||
|
|||||||
+3
-20
@@ -12,7 +12,7 @@ gitea_urls:
|
|||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: banforge
|
- id: banforge
|
||||||
main: ./cmd/banforge/main.go
|
main: ./main.go
|
||||||
binary: banforge
|
binary: banforge
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
@@ -24,12 +24,9 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-s -w"
|
- "-s -w"
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
archives:
|
|
||||||
- formats: [tar.gz]
|
|
||||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: banforge
|
- id: banforge
|
||||||
@@ -40,23 +37,9 @@ nfpms:
|
|||||||
maintainer: d3m0k1d <contact@d3m0k1d.ru>
|
maintainer: d3m0k1d <contact@d3m0k1d.ru>
|
||||||
license: GPLv3.0
|
license: GPLv3.0
|
||||||
formats:
|
formats:
|
||||||
- apk
|
|
||||||
- deb
|
- deb
|
||||||
- rpm
|
- rpm
|
||||||
- archlinux
|
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
scripts:
|
|
||||||
postinstall: build/postinstall.sh
|
|
||||||
postremove: build/postremove.sh
|
|
||||||
contents:
|
|
||||||
- src: docs/man/banforge.1
|
|
||||||
dst: /usr/share/man/man1/banforge.1
|
|
||||||
file_info:
|
|
||||||
mode: 0644
|
|
||||||
- src: docs/man/banforge.5
|
|
||||||
dst: /usr/share/man/man5/banforge.5
|
|
||||||
file_info:
|
|
||||||
mode: 0644
|
|
||||||
release:
|
release:
|
||||||
gitea:
|
gitea:
|
||||||
owner: d3m0k1d
|
owner: d3m0k1d
|
||||||
@@ -74,7 +57,7 @@ checksum:
|
|||||||
algorithm: sha256
|
algorithm: sha256
|
||||||
|
|
||||||
sboms:
|
sboms:
|
||||||
- artifacts: archive
|
- artifacts: any
|
||||||
documents:
|
documents:
|
||||||
- "{{ .ArtifactName }}.spdx.json"
|
- "{{ .ArtifactName }}.spdx.json"
|
||||||
cmd: syft
|
cmd: syft
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Path *string `yaml:"path"`
|
Path *string `yaml:"path"`
|
||||||
|
SystemdUnit *string `yaml:"systemd_unit"` // Optional: systemd unit name for health check
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
// ServiceStatus represents the unified status of a service across all monitor types.
|
||||||
|
type ServiceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusRunning ServiceStatus = "running"
|
||||||
|
StatusStopped ServiceStatus = "stopped"
|
||||||
|
StatusDegraded ServiceStatus = "degraded"
|
||||||
|
StatusPending ServiceStatus = "pending"
|
||||||
|
StatusUnknown ServiceStatus = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsHealthy reports whether the service is stable enough for dependents to rely on.
|
||||||
|
func (s ServiceStatus) IsHealthy() bool {
|
||||||
|
return s == StatusRunning
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Name string
|
Name string
|
||||||
Status string
|
Status ServiceStatus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,23 @@ func (self *DockerMonitor) CheckServices(ctx context.Context) ([]models.Service,
|
|||||||
return lo.Map(ctrs.Items, func(item container.Summary, _ int) models.Service {
|
return lo.Map(ctrs.Items, func(item container.Summary, _ int) models.Service {
|
||||||
return models.Service{
|
return models.Service{
|
||||||
Name: lo.If(len(item.Names) > 0, item.Names[0]).Else(item.ID),
|
Name: lo.If(len(item.Names) > 0, item.Names[0]).Else(item.ID),
|
||||||
Status: string(item.State), // TODO: map to standartized states enum
|
Status: mapContainerState(string(item.State)),
|
||||||
}
|
}
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapContainerState maps Docker container states to unified ServiceStatus.
|
||||||
|
func mapContainerState(state string) models.ServiceStatus {
|
||||||
|
switch state {
|
||||||
|
case "running":
|
||||||
|
return models.StatusRunning
|
||||||
|
case "exited", "dead":
|
||||||
|
return models.StatusStopped
|
||||||
|
case "paused":
|
||||||
|
return models.StatusDegraded
|
||||||
|
case "restarting", "created", "removing":
|
||||||
|
return models.StatusPending
|
||||||
|
default:
|
||||||
|
return models.StatusUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,23 @@ func (self *KubesMonitor) CheckServices(ctx context.Context) ([]models.Service,
|
|||||||
return lo.Map(pods.Items, func(item corev1.Pod, _ int) models.Service {
|
return lo.Map(pods.Items, func(item corev1.Pod, _ int) models.Service {
|
||||||
return models.Service{
|
return models.Service{
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Status: string(item.Status.Phase), // TODO: map to standartized states enum
|
Status: mapPodPhase(item.Status.Phase),
|
||||||
}
|
}
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapPodPhase maps K8s pod phases to unified ServiceStatus.
|
||||||
|
func mapPodPhase(phase corev1.PodPhase) models.ServiceStatus {
|
||||||
|
switch phase {
|
||||||
|
case corev1.PodRunning:
|
||||||
|
return models.StatusRunning
|
||||||
|
case corev1.PodSucceeded:
|
||||||
|
return models.StatusStopped
|
||||||
|
case corev1.PodFailed:
|
||||||
|
return models.StatusStopped
|
||||||
|
case corev1.PodPending:
|
||||||
|
return models.StatusPending
|
||||||
|
default:
|
||||||
|
return models.StatusUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+48
-2
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -329,7 +330,6 @@ func reconnectStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reportServices periodically sends service status updates to the backend via gRPC.
|
// 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(
|
func reportServices(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
grpcAddr string,
|
grpcAddr string,
|
||||||
@@ -352,9 +352,10 @@ func reportServices(
|
|||||||
for {
|
for {
|
||||||
svcUpdates := make([]*proto.ServicesUpdate_ServiceUpdate, 0, len(services))
|
svcUpdates := make([]*proto.ServicesUpdate_ServiceUpdate, 0, len(services))
|
||||||
for _, svc := range services {
|
for _, svc := range services {
|
||||||
|
status := checkServiceStatus(svc, lgr)
|
||||||
svcUpdates = append(svcUpdates, &proto.ServicesUpdate_ServiceUpdate{
|
svcUpdates = append(svcUpdates, &proto.ServicesUpdate_ServiceUpdate{
|
||||||
Name: svc.Name,
|
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.
|
// reportSystemMetrics periodically collects and sends system metrics to the backend via gRPC.
|
||||||
func reportSystemMetrics(
|
func reportSystemMetrics(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
+18
-7
@@ -110,7 +110,7 @@ func main() {
|
|||||||
if graphPath == "" {
|
if graphPath == "" {
|
||||||
graphPath = "/etc/hellreign/services.yaml"
|
graphPath = "/etc/hellreign/services.yaml"
|
||||||
}
|
}
|
||||||
graphHandlers := handlers.NewGraphHandlers(graphPath)
|
graphHandlers := handlers.NewGraphHandlers(graphPath, coll)
|
||||||
|
|
||||||
agents := handlers.NewAgentsGroup(h, coll)
|
agents := handlers.NewAgentsGroup(h, coll)
|
||||||
auth := handlers.AuthGroup{Handlers: h}
|
auth := handlers.AuthGroup{Handlers: h}
|
||||||
@@ -218,14 +218,25 @@ func main() {
|
|||||||
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
|
jobsGroup.GET("/metrics", jobsHandlers.GetJobMetrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service dependency graph (requires admin permission)
|
// Service dependency graph
|
||||||
graphGroup := v1.Group("/graph")
|
graphGroup := v1.Group("/graph")
|
||||||
graphGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
graphGroup.Use(auth.AuthMiddleware())
|
||||||
{
|
{
|
||||||
graphGroup.GET("", graphHandlers.GetYAML)
|
// Read-only endpoints: GET (require view)
|
||||||
graphGroup.PUT("", graphHandlers.UpdateYAML)
|
graphView := graphGroup.Group("")
|
||||||
graphGroup.GET("/order", graphHandlers.StartupOrder)
|
graphView.Use(handlers.RequireView())
|
||||||
graphGroup.GET("/cycle", graphHandlers.CycleCheck)
|
{
|
||||||
|
graphView.GET("", graphHandlers.GetGraph)
|
||||||
|
graphView.GET("/order", graphHandlers.StartupOrder)
|
||||||
|
graphView.GET("/cycle", graphHandlers.CycleCheck)
|
||||||
|
graphView.GET("/failure", graphHandlers.GetFailureRootCause)
|
||||||
|
}
|
||||||
|
// Write endpoints: PUT (require admin)
|
||||||
|
graphAdmin := graphGroup.Group("")
|
||||||
|
graphAdmin.Use(handlers.RequireAdmin())
|
||||||
|
{
|
||||||
|
graphAdmin.PUT("", graphHandlers.UpdateYAML)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent registration
|
// Agent registration
|
||||||
|
|||||||
+89
-5
@@ -1001,19 +1001,20 @@ const docTemplate = `{
|
|||||||
"Bearer": []
|
"Bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Returns the service dependency graph as raw YAML text",
|
"description": "Returns the service dependency graph as JSON",
|
||||||
"produces": [
|
"produces": [
|
||||||
"text/plain"
|
"application/json"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"graph"
|
"graph"
|
||||||
],
|
],
|
||||||
"summary": "Get dependency graph YAML",
|
"summary": "Get dependency graph",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "YAML content",
|
"description": "Dependency graph",
|
||||||
"schema": {
|
"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": {
|
"/graph/order": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"internal_handlers.InsertLogRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -3061,6 +3128,23 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"internal_handlers.ServiceStatusOut": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"healthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"node_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityDefinitions": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -990,19 +990,20 @@
|
|||||||
"Bearer": []
|
"Bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Returns the service dependency graph as raw YAML text",
|
"description": "Returns the service dependency graph as JSON",
|
||||||
"produces": [
|
"produces": [
|
||||||
"text/plain"
|
"application/json"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"graph"
|
"graph"
|
||||||
],
|
],
|
||||||
"summary": "Get dependency graph YAML",
|
"summary": "Get dependency graph",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "YAML content",
|
"description": "Dependency graph",
|
||||||
"schema": {
|
"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": {
|
"/graph/order": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"internal_handlers.InsertLogRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -3050,6 +3117,23 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"internal_handlers.ServiceStatusOut": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"healthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"node_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityDefinitions": {
|
"securityDefinitions": {
|
||||||
|
|||||||
@@ -417,6 +417,17 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- path
|
- path
|
||||||
type: object
|
type: object
|
||||||
|
internal_handlers.FailureRootCauseOut:
|
||||||
|
properties:
|
||||||
|
affected:
|
||||||
|
$ref: '#/definitions/internal_handlers.ServiceStatusOut'
|
||||||
|
dependency_chain:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
root_cause:
|
||||||
|
$ref: '#/definitions/internal_handlers.ServiceStatusOut'
|
||||||
|
type: object
|
||||||
internal_handlers.InsertLogRequest:
|
internal_handlers.InsertLogRequest:
|
||||||
properties:
|
properties:
|
||||||
agent:
|
agent:
|
||||||
@@ -527,6 +538,17 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- token
|
- token
|
||||||
type: object
|
type: object
|
||||||
|
internal_handlers.ServiceStatusOut:
|
||||||
|
properties:
|
||||||
|
healthy:
|
||||||
|
type: boolean
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
node_id:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
info:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
paths:
|
paths:
|
||||||
@@ -1159,17 +1181,18 @@ paths:
|
|||||||
- auth
|
- auth
|
||||||
/graph:
|
/graph:
|
||||||
get:
|
get:
|
||||||
description: Returns the service dependency graph as raw YAML text
|
description: Returns the service dependency graph as JSON
|
||||||
produces:
|
produces:
|
||||||
- text/plain
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: YAML content
|
description: Dependency graph
|
||||||
schema:
|
schema:
|
||||||
type: string
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
security:
|
security:
|
||||||
- Bearer: []
|
- Bearer: []
|
||||||
summary: Get dependency graph YAML
|
summary: Get dependency graph
|
||||||
tags:
|
tags:
|
||||||
- graph
|
- graph
|
||||||
put:
|
put:
|
||||||
@@ -1220,6 +1243,38 @@ paths:
|
|||||||
summary: Check for cycles
|
summary: Check for cycles
|
||||||
tags:
|
tags:
|
||||||
- graph
|
- 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:
|
/graph/order:
|
||||||
get:
|
get:
|
||||||
description: Returns the topologically sorted service startup order
|
description: Returns the topologically sorted service startup order
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
@@ -23,6 +24,9 @@ func (c *Collector) ReportServices(ctx context.Context, req *proto.ServicesUpdat
|
|||||||
}
|
}
|
||||||
agentName := whoamiVals[0]
|
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))
|
services := make([]Service, 0, len(req.Services))
|
||||||
for _, s := range req.Services {
|
for _, s := range req.Services {
|
||||||
services = append(services, Service{s.Name, s.Status})
|
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]
|
agentName := whoamiVals[0]
|
||||||
|
|
||||||
|
// Auto-register agent if not yet known (e.g. log stream not connected yet)
|
||||||
|
c.ensureAgentRegistered(agentName)
|
||||||
|
|
||||||
metrics := SystemMetrics{
|
metrics := SystemMetrics{
|
||||||
CPUPercent: req.CpuPercent,
|
CPUPercent: req.CpuPercent,
|
||||||
MemoryPercent: req.MemoryPercent,
|
MemoryPercent: req.MemoryPercent,
|
||||||
@@ -68,3 +75,18 @@ func (c *Collector) ReportSystemMetrics(ctx context.Context, req *proto.SystemMe
|
|||||||
|
|
||||||
return &proto.SystemMetricsResp{}, nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,23 +5,26 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/graph"
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/graph"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GraphHandlers manages the service dependency graph.
|
// GraphHandlers manages the service dependency graph.
|
||||||
type GraphHandlers struct {
|
type GraphHandlers struct {
|
||||||
path string
|
path string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
yamlData []byte
|
yamlData []byte
|
||||||
loaded *graph.Graph
|
loaded *graph.Graph
|
||||||
|
collector *collector.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGraphHandlers loads the graph from the given YAML file path.
|
// NewGraphHandlers loads the graph from the given YAML file path.
|
||||||
func NewGraphHandlers(yamlPath string) *GraphHandlers {
|
func NewGraphHandlers(yamlPath string, coll *collector.Collector) *GraphHandlers {
|
||||||
h := &GraphHandlers{path: yamlPath}
|
h := &GraphHandlers{path: yamlPath, collector: coll}
|
||||||
if err := h.reload(); err != nil {
|
if err := h.reload(); err != nil {
|
||||||
if _, ok := err.(*os.PathError); ok {
|
if _, ok := err.(*os.PathError); ok {
|
||||||
log.Printf("[graph] no graph file at %q, starting with empty graph", yamlPath)
|
log.Printf("[graph] no graph file at %q, starting with empty graph", yamlPath)
|
||||||
@@ -50,25 +53,52 @@ func (h *GraphHandlers) reload() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGraph returns the current parsed graph.
|
// LoadedGraph returns the current parsed graph.
|
||||||
func (h *GraphHandlers) GetGraph() *graph.Graph {
|
func (h *GraphHandlers) LoadedGraph() *graph.Graph {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
return h.loaded
|
return h.loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetYAML returns the raw YAML content.
|
// GetGraph returns the current dependency graph as JSON.
|
||||||
// @Summary Get dependency graph YAML
|
// @Summary Get dependency graph
|
||||||
// @Description Returns the service dependency graph as raw YAML text
|
// @Description Returns the service dependency graph as JSON
|
||||||
// @Tags graph
|
// @Tags graph
|
||||||
// @Produce plain
|
// @Produce json
|
||||||
// @Success 200 {string} string "YAML content"
|
// @Success 200 {object} map[string]interface{} "Dependency graph"
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
// @Router /graph [get]
|
// @Router /graph [get]
|
||||||
func (h *GraphHandlers) GetYAML(c *gin.Context) {
|
func (h *GraphHandlers) GetGraph(c *gin.Context) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
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.
|
// UpdateYAML updates the graph from new YAML text.
|
||||||
@@ -147,3 +177,209 @@ func (h *GraphHandlers) CycleCheck(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"has_cycle": g.HasCycle()})
|
c.JSON(http.StatusOK, gin.H{"has_cycle": g.HasCycle()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServiceStatusOut represents a service and its current status.
|
||||||
|
type ServiceStatusOut struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailureRootCauseOut represents the result of a failure analysis.
|
||||||
|
type FailureRootCauseOut struct {
|
||||||
|
Affected ServiceStatusOut `json:"affected"`
|
||||||
|
RootCause *ServiceStatusOut `json:"root_cause,omitempty"`
|
||||||
|
DependencyChain []string `json:"dependency_chain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFailureRootCause analyzes the dependency graph and current service
|
||||||
|
// statuses to find the root cause of a service failure.
|
||||||
|
// If the specified service is unhealthy, it traverses its dependencies
|
||||||
|
// to find the first unhealthy dependency — the one that is the root cause.
|
||||||
|
// @Summary Find failure root cause
|
||||||
|
// @Description Analyzes dependencies and service statuses to find the root cause of a failure
|
||||||
|
// @Tags graph
|
||||||
|
// @Param node_id query string false "Node ID (agent label)"
|
||||||
|
// @Param service query string true "Service name"
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} FailureRootCauseOut
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /graph/failure [get]
|
||||||
|
func (h *GraphHandlers) GetFailureRootCause(c *gin.Context) {
|
||||||
|
nodeID := c.Query("node_id")
|
||||||
|
svcName := c.Query("service")
|
||||||
|
if svcName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "service query param is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
g := h.loaded
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if g == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no graph loaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of service statuses from all agents
|
||||||
|
svcStatus := h.buildServiceStatusMap()
|
||||||
|
|
||||||
|
// If no node specified, search all nodes for the service
|
||||||
|
if nodeID == "" {
|
||||||
|
for _, node := range g.Nodes() {
|
||||||
|
if _, ok := g.GetService(node.ID, svcName); ok {
|
||||||
|
nodeID = node.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodeID == "" {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "service not found in graph"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := g.GetService(nodeID, svcName); !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "service not found in node"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current status
|
||||||
|
status := svcStatus[nodeID+":"+svcName]
|
||||||
|
affected := ServiceStatusOut{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Name: svcName,
|
||||||
|
Status: status.status,
|
||||||
|
Healthy: status.healthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the service is healthy, no failure to analyze
|
||||||
|
if status.healthy {
|
||||||
|
c.JSON(http.StatusOK, FailureRootCauseOut{
|
||||||
|
Affected: affected,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root cause: traverse dependencies to find the first unhealthy one
|
||||||
|
rootCause, chain := findRootCause(g, nodeID, svcName, svcStatus)
|
||||||
|
|
||||||
|
out := FailureRootCauseOut{
|
||||||
|
Affected: affected,
|
||||||
|
DependencyChain: chain,
|
||||||
|
}
|
||||||
|
if rootCause != nil {
|
||||||
|
out.RootCause = rootCause
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// svcStatusEntry holds parsed status info.
|
||||||
|
type svcStatusEntry struct {
|
||||||
|
status string
|
||||||
|
healthy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServiceStatusMap creates a map of "nodeID:serviceName" → status.
|
||||||
|
// Matches graph nodes to agent labels in the collector.
|
||||||
|
func (h *GraphHandlers) buildServiceStatusMap() map[string]svcStatusEntry {
|
||||||
|
result := make(map[string]svcStatusEntry)
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
nodes := h.loaded.Nodes()
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, agent := range h.collector.Agents() {
|
||||||
|
for _, svc := range agent.Services {
|
||||||
|
healthy := isHealthyStatus(svc.Status)
|
||||||
|
entry := svcStatusEntry{status: svc.Status, healthy: healthy}
|
||||||
|
|
||||||
|
// Try exact node match first
|
||||||
|
key := agent.Label + ":" + svc.Name
|
||||||
|
result[key] = entry
|
||||||
|
|
||||||
|
// Also register under all nodes that don't have a status yet
|
||||||
|
for _, node := range nodes {
|
||||||
|
nodeKey := node.ID + ":" + svc.Name
|
||||||
|
if _, exists := result[nodeKey]; !exists {
|
||||||
|
result[nodeKey] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRootCause traverses the dependency graph to find the first unhealthy dependency.
|
||||||
|
func findRootCause(g *graph.Graph, nodeID, svcName string, statusMap map[string]svcStatusEntry) (*ServiceStatusOut, []string) {
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
var chain []string
|
||||||
|
|
||||||
|
var dfs func(string, string) *ServiceStatusOut
|
||||||
|
dfs = func(nid, sname string) *ServiceStatusOut {
|
||||||
|
key := nid + ":" + sname
|
||||||
|
chain = append(chain, key)
|
||||||
|
visited[key] = true
|
||||||
|
|
||||||
|
svc, ok := g.GetService(nid, sname)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each dependency
|
||||||
|
for _, dep := range svc.Dependencies {
|
||||||
|
depNodeID := dep.Target.NodeID
|
||||||
|
if depNodeID == "" {
|
||||||
|
depNodeID = nid
|
||||||
|
}
|
||||||
|
depKey := depNodeID + ":" + dep.Target.Name
|
||||||
|
|
||||||
|
if visited[depKey] {
|
||||||
|
continue // avoid loops
|
||||||
|
}
|
||||||
|
|
||||||
|
depStatus := statusMap[depKey]
|
||||||
|
|
||||||
|
if !depStatus.healthy {
|
||||||
|
// This dependency is unhealthy — check if IT has an unhealthy dependency
|
||||||
|
// (to find the true root cause)
|
||||||
|
if deeper := dfs(depNodeID, dep.Target.Name); deeper != nil {
|
||||||
|
return deeper
|
||||||
|
}
|
||||||
|
// This is the root cause
|
||||||
|
return &ServiceStatusOut{
|
||||||
|
NodeID: depNodeID,
|
||||||
|
Name: dep.Target.Name,
|
||||||
|
Status: depStatus.status,
|
||||||
|
Healthy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root := dfs(nodeID, svcName)
|
||||||
|
|
||||||
|
// Deduplicate chain
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var deduped []string
|
||||||
|
for _, k := range chain {
|
||||||
|
if !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
deduped = append(deduped, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root, deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHealthyStatus(status string) bool {
|
||||||
|
s := strings.ToLower(status)
|
||||||
|
return s == "running" || s == "up" || s == "healthy"
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"Bash(type *)",
|
"Bash(type *)",
|
||||||
"Bash(dir)",
|
"Bash(dir)",
|
||||||
"Bash(move *)",
|
"Bash(move *)",
|
||||||
"Bash(findstr *)"
|
"Bash(findstr *)",
|
||||||
|
"Bash(del *)",
|
||||||
|
"Bash(mkdir *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
Generated
+7152
File diff suppressed because it is too large
Load Diff
@@ -11,19 +11,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@uiw/react-codemirror": "^4.25.8",
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"file-surf": "^1.0.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"monaco-languageclient": "^10.7.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.7",
|
"primereact": "^10.9.7",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vscode-ws-jsonrpc": "^3.5.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import "@/shared/styles/index.css";
|
import "@/shared/styles/index.css";
|
||||||
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
||||||
import "primereact/resources/primereact.min.css";
|
import "primereact/resources/primereact.min.css";
|
||||||
import "primeicons/primeicons.css";
|
import "primeicons/primeicons.css";
|
||||||
import { PrimeReactProvider } from "primereact/api";
|
import { PrimeReactProvider } from "primereact/api";
|
||||||
import { Routing } from "./providers/routing/routing";
|
import { Routing } from "./providers/routing/routing";
|
||||||
|
import { AppLoader } from "./components/AppLoader";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <AppLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimeReactProvider>
|
<PrimeReactProvider>
|
||||||
<Routing />
|
<Routing />
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FaMicrochip, FaCode, FaNetworkWired, FaAtom } from "react-icons/fa";
|
||||||
|
|
||||||
|
export const AppLoader = () => {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [phase, setPhase] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const phases = [
|
||||||
|
{ progress: 25, delay: 400 },
|
||||||
|
{ progress: 50, delay: 300 },
|
||||||
|
{ progress: 75, delay: 400 },
|
||||||
|
{ progress: 100, delay: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let timeouts: NodeJS.Timeout[] = [];
|
||||||
|
let currentDelay = 0;
|
||||||
|
|
||||||
|
phases.forEach((p, i) => {
|
||||||
|
currentDelay += p.delay;
|
||||||
|
timeouts.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
setProgress(p.progress);
|
||||||
|
setPhase(i);
|
||||||
|
}, currentDelay),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => timeouts.forEach(clearTimeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "#0a0a0f",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 9999,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background grid effect */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
animation: "gridMove 20s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glowing orbs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "300px",
|
||||||
|
height: "300px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)",
|
||||||
|
filter: "blur(40px)",
|
||||||
|
animation: "orbFloat 6s ease-in-out infinite",
|
||||||
|
top: "20%",
|
||||||
|
left: "30%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "250px",
|
||||||
|
height: "250px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)",
|
||||||
|
filter: "blur(40px)",
|
||||||
|
animation: "orbFloat 8s ease-in-out infinite reverse",
|
||||||
|
bottom: "20%",
|
||||||
|
right: "30%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
|
||||||
|
{/* Logo with animation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "16px",
|
||||||
|
marginBottom: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
animation: "logoSpin 3s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaAtom size={48} style={{ color: "#3b82f6" }} />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "42px",
|
||||||
|
fontWeight: 800,
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
letterSpacing: "4px",
|
||||||
|
animation: "titleGlow 2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading icons animation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "24px",
|
||||||
|
marginBottom: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ icon: FaMicrochip, delay: "0s" },
|
||||||
|
{ icon: FaNetworkWired, delay: "0.2s" },
|
||||||
|
{ icon: FaCode, delay: "0.4s" },
|
||||||
|
].map(({ icon: Icon, delay }, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `2px solid ${
|
||||||
|
phase >= i
|
||||||
|
? "rgba(59, 130, 246, 0.6)"
|
||||||
|
: "rgba(255,255,255,0.1)"
|
||||||
|
}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor:
|
||||||
|
phase >= i ? "rgba(59, 130, 246, 0.1)" : "transparent",
|
||||||
|
animation: `iconPop 0.5s ease-out ${delay} both`,
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={22}
|
||||||
|
style={{
|
||||||
|
color: phase >= i ? "#3b82f6" : "#555",
|
||||||
|
transition: "color 0.3s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "320px",
|
||||||
|
height: "4px",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: `${progress}%`,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
transition: "width 0.4s ease",
|
||||||
|
boxShadow: "0 0 20px rgba(59, 130, 246, 0.5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
letterSpacing: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phase === 0 && "INITIALIZING CORE..."}
|
||||||
|
{phase === 1 && "LOADING AGENTS..."}
|
||||||
|
{phase === 2 && "ESTABLISHING CONNECTIONS..."}
|
||||||
|
{phase === 3 && "READY"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS Animations */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes gridMove {
|
||||||
|
0% { transform: translate(0, 0); }
|
||||||
|
100% { transform: translate(40px, 40px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbFloat {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoSpin {
|
||||||
|
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||||
|
25% { transform: rotate(-10deg) scale(1.05); }
|
||||||
|
75% { transform: rotate(10deg) scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes titleGlow {
|
||||||
|
0%, 100% { filter: brightness(1); }
|
||||||
|
50% { filter: brightness(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iconPop {
|
||||||
|
0% { transform: scale(0.5) translateY(10px); opacity: 0; }
|
||||||
|
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
|
||||||
|
import {
|
||||||
|
Navigation,
|
||||||
|
BottomNav,
|
||||||
|
} from "@/app/providers/layout/navigation/navigation";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
|
||||||
|
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < 856 : false,
|
||||||
|
);
|
||||||
|
const [isVerySmall, setIsVerySmall] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < 600 : false,
|
||||||
|
);
|
||||||
|
const { fetchAgents } = useAgentStore();
|
||||||
|
|
||||||
|
const sidebarOpen = isMobile ? mobileOpen : true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
const mobile = window.innerWidth < 856;
|
||||||
|
setIsMobile(mobile);
|
||||||
|
if (!mobile) {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}
|
||||||
|
setIsVerySmall(window.innerWidth < 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
handleResize();
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-screen overflow-hidden"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onToggle={toggleSidebar}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<Navigation
|
||||||
|
onToggleSidebar={toggleSidebar}
|
||||||
|
isMobile={isMobile}
|
||||||
|
isVerySmall={isVerySmall}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||||
|
{isVerySmall && <BottomNav />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
|
||||||
|
import {
|
||||||
|
FaHome,
|
||||||
|
FaServer,
|
||||||
|
FaUser,
|
||||||
|
FaUsers,
|
||||||
|
FaRocket,
|
||||||
|
FaKey,
|
||||||
|
FaFileAlt,
|
||||||
|
FaPalette,
|
||||||
|
FaSignOutAlt,
|
||||||
|
FaShieldAlt,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||||
|
import { themes } from "@/modules/theme-changer/config/theme.config";
|
||||||
|
import {
|
||||||
|
applyTheme,
|
||||||
|
getCurrentTheme,
|
||||||
|
} from "@/modules/theme-changer/utils/apply.theme";
|
||||||
|
|
||||||
|
interface NavigationProps {
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
isVerySmall?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Navigation: React.FC<NavigationProps> = ({
|
||||||
|
onToggleSidebar,
|
||||||
|
isMobile,
|
||||||
|
isVerySmall = false,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const { setTheme } = useThemeStore();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentTheme = getCurrentTheme();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
||||||
|
{
|
||||||
|
path: "/add-agents",
|
||||||
|
label: "Деплой",
|
||||||
|
icon: FaRocket,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/registration",
|
||||||
|
label: "Регистрация",
|
||||||
|
icon: FaKey,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
// Filter nav items based on user permissions
|
||||||
|
const filteredNavItems = navItems.filter((item) => {
|
||||||
|
if (item.requireView && !user?.permission_view) return false;
|
||||||
|
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setThemePickerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/auth");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (themeId: string) => {
|
||||||
|
applyTheme(themeId);
|
||||||
|
setTheme(themeId as any);
|
||||||
|
setThemePickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNavItems = (showLabels: boolean, iconSize: number) => (
|
||||||
|
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
{filteredNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg font-medium transition-all flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||||
|
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!active) {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!active) {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<Icon size={iconSize} />
|
||||||
|
{showLabels && <span className="text-xs">{item.label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Верхний бар */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-b"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5">
|
||||||
|
{/* Бургер — только на мобильных */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
aria-label="Открыть sidebar"
|
||||||
|
>
|
||||||
|
<FaBars size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Название по центру — только на очень маленьких экранах */}
|
||||||
|
{isVerySmall && (
|
||||||
|
<div className="flex-1 text-center mx-4">
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Навигация — только если НЕ очень маленький экран */}
|
||||||
|
{!isVerySmall && (
|
||||||
|
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
|
||||||
|
{renderNavItems(true, 12)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Профиль пользователя — дропдаун */}
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: dropdownOpen
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{user?.name || user?.login || "Пользователь"}
|
||||||
|
</span>
|
||||||
|
<FaChevronDown
|
||||||
|
size={10}
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
|
||||||
|
transition: "transform 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minWidth: "220px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<FaUser
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{user?.name || user?.login}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{user?.login}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setThemePickerOpen(!themePickerOpen)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPalette
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-left">
|
||||||
|
Тема: {themes.find((t) => t.id === currentTheme)?.name}
|
||||||
|
</span>
|
||||||
|
<FaChevronDown
|
||||||
|
size={9}
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
transform: themePickerOpen
|
||||||
|
? "rotate(180deg)"
|
||||||
|
: "rotate(0)",
|
||||||
|
transition: "transform 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{themePickerOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minWidth: "180px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{themes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => handleThemeChange(t.id)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentTheme === t.id
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--text-primary)",
|
||||||
|
backgroundColor:
|
||||||
|
currentTheme === t.id
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentTheme !== t.id) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentTheme !== t.id) {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: t.colors.primary,
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.permission_admin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
navigate("/admin");
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
|
||||||
|
<span>Админка</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="my-1 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
|
||||||
|
style={{ color: "var(--error-text)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(239, 68, 68, 0.1)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSignOutAlt size={12} />
|
||||||
|
<span>Выйти</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BottomNav: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
||||||
|
{
|
||||||
|
path: "/add-agents",
|
||||||
|
label: "Деплой",
|
||||||
|
icon: FaRocket,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/registration",
|
||||||
|
label: "Регистрация",
|
||||||
|
icon: FaKey,
|
||||||
|
requireManageAgent: true,
|
||||||
|
},
|
||||||
|
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
// Filter nav items based on user permissions
|
||||||
|
const filteredNavItems = navItems.filter((item) => {
|
||||||
|
if (item.requireView && !user?.permission_view) return false;
|
||||||
|
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-t"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-around px-2 py-2">
|
||||||
|
{filteredNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className="flex items-center justify-center p-3 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? "var(--accent)" : "transparent",
|
||||||
|
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
FaBars,
|
||||||
|
FaMicrochip,
|
||||||
|
FaTimes,
|
||||||
|
FaSpinner,
|
||||||
|
FaCopy,
|
||||||
|
FaCheck,
|
||||||
|
FaChevronRight,
|
||||||
|
FaChevronDown,
|
||||||
|
FaProjectDiagram,
|
||||||
|
FaTrash,
|
||||||
|
FaArrowLeft,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import { adminApi } from "@/modules/admin/api/admin.api";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
isOpen = true,
|
||||||
|
onToggle,
|
||||||
|
isMobile = false,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { agents, isLoading, error, fetchAgents, removeAgent } =
|
||||||
|
useAgentStore();
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||||
|
const [showGraphs, setShowGraphs] = useState(false);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(288);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
|
||||||
|
new Set(agents.map((a) => a.label)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Рассчитываем максимальную ширину при переключении на графы
|
||||||
|
useEffect(() => {
|
||||||
|
const updateWidth = () => {
|
||||||
|
const targetWidth = showGraphs ? 500 : 288;
|
||||||
|
const maxWidth = window.innerWidth - 200;
|
||||||
|
const finalWidth = Math.min(targetWidth, maxWidth);
|
||||||
|
setSidebarWidth(Math.max(finalWidth, 250));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidth();
|
||||||
|
window.addEventListener("resize", updateWidth);
|
||||||
|
return () => window.removeEventListener("resize", updateWidth);
|
||||||
|
}, [showGraphs]);
|
||||||
|
|
||||||
|
// Token generation state
|
||||||
|
const [tokenLabel, setTokenLabel] = useState("");
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
|
const [tokenGenerating, setTokenGenerating] = useState(false);
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleAgent = (label: string) => {
|
||||||
|
setExpandedAgents((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(label)) next.delete(label);
|
||||||
|
else next.add(label);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
if (!searchQuery) return agents;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.label.toLowerCase().includes(query) ||
|
||||||
|
agent.services.some((s) => s.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
|
const [graphData, setGraphData] = useState<GraphData>({
|
||||||
|
nodes: [],
|
||||||
|
links: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGraph = () => {
|
||||||
|
agentApiService
|
||||||
|
.getGraph()
|
||||||
|
.then((apiData) => {
|
||||||
|
const nodes: any[] = [];
|
||||||
|
const links: any[] = [];
|
||||||
|
|
||||||
|
// Build a map of service statuses from agents
|
||||||
|
const serviceStatusMap = new Map<string, "up" | "down">();
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
const services = agent.services || [];
|
||||||
|
services.forEach((svc: string) => {
|
||||||
|
const parts = svc.split(":");
|
||||||
|
const svcName = parts[0];
|
||||||
|
const status = parts[1] === "down" ? "down" : "up";
|
||||||
|
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(apiData.nodes || {}).forEach(
|
||||||
|
([agentLabel, agentNode]: [string, any]) => {
|
||||||
|
nodes.push({
|
||||||
|
id: agentLabel,
|
||||||
|
name: agentLabel,
|
||||||
|
type: "agent" as const,
|
||||||
|
val: 8,
|
||||||
|
description: `Агент: ${agentLabel}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const services = agentNode?.services || {};
|
||||||
|
Object.entries(services).forEach(
|
||||||
|
([serviceName, serviceNode]: [string, any]) => {
|
||||||
|
const serviceId = `${agentLabel}-${serviceName}`;
|
||||||
|
const status = serviceStatusMap.get(serviceId) || "up";
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: serviceId,
|
||||||
|
name: serviceName,
|
||||||
|
type: "service" as const,
|
||||||
|
val: 12,
|
||||||
|
description: `Сервис: ${serviceName}`,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
source: agentLabel,
|
||||||
|
target: serviceId,
|
||||||
|
type: "hosts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dependencies = serviceNode?.dependencies || [];
|
||||||
|
dependencies.forEach((dep: any) => {
|
||||||
|
const targetName = dep?.target?.name;
|
||||||
|
if (targetName) {
|
||||||
|
links.push({
|
||||||
|
source: serviceId,
|
||||||
|
target: `${agentLabel}-${targetName}`,
|
||||||
|
type: dep.condition || "dependency",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setGraphData({ nodes, links });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Failed to fetch graph:", e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGraph();
|
||||||
|
const interval = setInterval(fetchGraph, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const handleCopyToken = () => {
|
||||||
|
const tokenToCopy = generatedToken || token;
|
||||||
|
if (tokenToCopy) {
|
||||||
|
navigator.clipboard.writeText(tokenToCopy);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateToken = async () => {
|
||||||
|
if (!tokenLabel.trim()) return;
|
||||||
|
setTokenGenerating(true);
|
||||||
|
setTokenError(null);
|
||||||
|
try {
|
||||||
|
const newToken = await adminApi.generateToken(tokenLabel.trim());
|
||||||
|
setGeneratedToken(newToken);
|
||||||
|
} catch (e) {
|
||||||
|
setTokenError(
|
||||||
|
e instanceof Error ? e.message : "Failed to generate token",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTokenGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTokenModal = () => {
|
||||||
|
setShowTokenModal(false);
|
||||||
|
setTokenLabel("");
|
||||||
|
setGeneratedToken(null);
|
||||||
|
setTokenError(null);
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay — только на мобильных (< 856px) */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
|
||||||
|
style={{
|
||||||
|
width: `${sidebarWidth}px`,
|
||||||
|
height: "100vh",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Агенты
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agents.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`p-1 rounded transition-colors ${isMobile ? "" : "hidden"}`}
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
aria-label="Закрыть sidebar"
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент — либо список агентов, либо графы */}
|
||||||
|
{showGraphs ? (
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
<Graph initialData={graphData} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск агентов..."
|
||||||
|
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border-focus)";
|
||||||
|
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список агентов */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{isLoading && agents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FaSpinner
|
||||||
|
className="animate-spin mb-3"
|
||||||
|
style={{ color: "var(--accent)", fontSize: "20px" }}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Загрузка агентов...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div
|
||||||
|
className="text-xs mb-2"
|
||||||
|
style={{ color: "var(--error-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchAgents}
|
||||||
|
className="text-xs hover:underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : filteredAgents.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="text-center py-8"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
|
||||||
|
<p className="text-xs">
|
||||||
|
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredAgents.map((agent) => {
|
||||||
|
const isExpanded = expandedAgents.has(agent.label);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.label}
|
||||||
|
className="rounded-lg border overflow-hidden transition-all group"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Agent header — кликабельный для сворачивания */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => toggleAgent(agent.label)}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronDown size={10} />
|
||||||
|
) : (
|
||||||
|
<FaChevronRight size={10} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<FaMicrochip
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium flex-1 truncate cursor-pointer"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/dashboard/${agent.label}`);
|
||||||
|
}}
|
||||||
|
title="Открыть дашборд агента"
|
||||||
|
>
|
||||||
|
{agent.label}
|
||||||
|
</span>
|
||||||
|
{/* Статус-индикатор агента (количество сервисов) */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{agent.services.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: "#4ade80" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
{agent.services.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Кнопка удаления — появляется при наведении */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Удалить агента "${agent.label}"?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
removeAgent(agent.label);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#f87171";
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(248, 113, 113, 0.15)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--text-muted)";
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"transparent";
|
||||||
|
}}
|
||||||
|
title="Удалить агента"
|
||||||
|
>
|
||||||
|
<FaTrash size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services list — сворачивается */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
className="px-3 pb-2"
|
||||||
|
style={{ paddingLeft: "24px" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="border-l-2 pl-3 space-y-1"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
{agent.services.map((service) => {
|
||||||
|
// Parse "serviceName:up" or "serviceName:down"
|
||||||
|
const parts = service.split(":");
|
||||||
|
const serviceName = parts[0];
|
||||||
|
const isDown = parts[1] === "down";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={service}
|
||||||
|
className="flex items-center justify-between py-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
color: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDown
|
||||||
|
? "#ef4444"
|
||||||
|
: "#4ade80",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
color: isDown ? "#ef4444" : "#4ade80",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDown ? "down" : "run"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer с кнопками */}
|
||||||
|
<div
|
||||||
|
className="p-2 border-t flex gap-2"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showGraphs ? (
|
||||||
|
/* Кнопка назад к агентам */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(false)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={10} />К агентам
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
/* Кнопка Графы */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowGraphs(true)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaProjectDiagram size={10} />
|
||||||
|
Графы
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTokenModal(true)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaCopy size={10} />
|
||||||
|
Токен
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Modal токена */}
|
||||||
|
{showTokenModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
onClick={handleCloseTokenModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl shadow-2xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaCopy style={{ color: "var(--accent)" }} size={14} />
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Генерация токена
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseTokenModal}
|
||||||
|
className="p-1 rounded transition-colors"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Error */}
|
||||||
|
{tokenError && (
|
||||||
|
<div
|
||||||
|
className="text-xs p-2 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(239,68,68,0.1)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
color: "var(--error-text, #ef4444)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tokenError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label input */}
|
||||||
|
{!generatedToken && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium mb-2"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Имя токена
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tokenLabel}
|
||||||
|
onChange={(e) => setTokenLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && tokenLabel.trim()) {
|
||||||
|
handleGenerateToken();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Введите имя..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generated token */}
|
||||||
|
{generatedToken && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-xs font-medium mb-2"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Токен
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-lg p-3 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className="flex-1 text-xs font-mono break-all"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{generatedToken}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
className="p-1.5 rounded transition-colors"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<FaCheck
|
||||||
|
size={12}
|
||||||
|
style={{ color: "var(--success-text)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaCopy size={12} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{generatedToken && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setGeneratedToken(null);
|
||||||
|
setTokenLabel("");
|
||||||
|
}}
|
||||||
|
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Новый токен
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={
|
||||||
|
generatedToken ? handleCloseTokenModal : handleGenerateToken
|
||||||
|
}
|
||||||
|
disabled={tokenGenerating || !tokenLabel.trim()}
|
||||||
|
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color:
|
||||||
|
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||||
|
? "var(--text-muted)"
|
||||||
|
: "var(--accent-text)",
|
||||||
|
cursor:
|
||||||
|
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
||||||
|
? "default"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tokenGenerating
|
||||||
|
? "Генерация..."
|
||||||
|
: generatedToken
|
||||||
|
? "Готово"
|
||||||
|
: "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import type { AgentInfo } from "@/modules/agent/types/agent.types";
|
||||||
|
|
||||||
|
interface AgentState {
|
||||||
|
agents: AgentInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchAgents: () => Promise<void>;
|
||||||
|
removeAgent: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAgentStore = create<AgentState>()((set, get) => ({
|
||||||
|
agents: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchAgents: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const agents = await agentApiService.getAgents();
|
||||||
|
set({ agents, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to fetch agents",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAgent: (name: string) => {
|
||||||
|
set({ agents: get().agents.filter((a) => a.label !== name) });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
||||||
|
import type { SystemMetrics } from "@/modules/agent/types/agent.types";
|
||||||
|
|
||||||
|
interface MetricsState {
|
||||||
|
metrics: SystemMetrics[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLLING_INTERVAL = 30_000;
|
||||||
|
|
||||||
|
let _pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export const useMetricsStore = create<MetricsState>(() => ({
|
||||||
|
metrics: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const startMetricsPolling = async () => {
|
||||||
|
if (_pollingTimer) return;
|
||||||
|
const fetchMetrics = async () => {
|
||||||
|
try {
|
||||||
|
const data = await agentApiService.getSystemMetrics();
|
||||||
|
useMetricsStore.setState({
|
||||||
|
metrics: data,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
useMetricsStore.setState({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to fetch metrics",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await fetchMetrics();
|
||||||
|
_pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopMetricsPolling = () => {
|
||||||
|
if (_pollingTimer) {
|
||||||
|
clearInterval(_pollingTimer);
|
||||||
|
_pollingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,12 +1,42 @@
|
|||||||
import { useAuthStore } from "@/store/auth/auth.store";
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
interface ProtectedRouteProps {
|
||||||
const { isAuthenticated } = useAuthStore();
|
children: React.ReactNode;
|
||||||
|
requireView?: boolean;
|
||||||
|
requireManageAgent?: boolean;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
fallbackPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
|
children,
|
||||||
|
requireView = false,
|
||||||
|
requireManageAgent = false,
|
||||||
|
requireAdmin = false,
|
||||||
|
fallbackPath = "/",
|
||||||
|
}) => {
|
||||||
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
if (!isAuthenticated && user?.token) {
|
||||||
|
// User is authenticated based on token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requireView && !user.permission_view) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireManageAgent && !user.permission_manage_agent) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && !user.permission_admin) {
|
||||||
|
return <Navigate to={fallbackPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,113 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
||||||
import { HomePage } from "@/pages/home.page";
|
import { HomePage } from "@/pages/home.page";
|
||||||
import { ThemesPage } from "@/pages/themes.page";
|
import { TestPage } from "@/pages/test.page";
|
||||||
|
import { Graph, type GraphData } from "@/modules/graph";
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
import { AuthPage } from "@/pages/auth.page";
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
import { RegisterPage } from "@/pages/register.page";
|
||||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||||
|
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||||
|
import { IDEPage } from "@/pages/ide.page";
|
||||||
|
import { TemplatesPage } from "@/pages/templates.page";
|
||||||
|
import { AdminPage } from "@/pages/admin.page";
|
||||||
|
import { RegistrationTokenPage } from "@/pages/registration.page";
|
||||||
|
import { LogsPage } from "@/pages/logs.page";
|
||||||
|
import { GraphsPage } from "@/pages/graphs.page";
|
||||||
|
import { DashboardPage } from "@/pages/dashboard.page";
|
||||||
|
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
|
||||||
|
import { ProtectedRoute } from "./helper/protected.route";
|
||||||
|
|
||||||
|
export const mockGraphData: GraphData = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "api-gateway",
|
||||||
|
name: "API Gateway",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Входная точка API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auth-service",
|
||||||
|
name: "Auth Service",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Аутентификация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "db-service",
|
||||||
|
name: "Database",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Хранилище данных",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redis-service",
|
||||||
|
name: "Redis",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Кэширование",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queue-service",
|
||||||
|
name: "Message Queue",
|
||||||
|
type: "service",
|
||||||
|
val: 12,
|
||||||
|
description: "Очередь сообщений",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-agent",
|
||||||
|
name: "User Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Обработка пользователей",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payment-agent",
|
||||||
|
name: "Payment Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Платежи",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notification-agent",
|
||||||
|
name: "Notification Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Уведомления",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analytics-agent",
|
||||||
|
name: "Analytics Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Аналитика",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "report-agent",
|
||||||
|
name: "Report Agent",
|
||||||
|
type: "agent",
|
||||||
|
val: 8,
|
||||||
|
description: "Отчеты",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ source: "user-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "user-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "api-gateway", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "auth-service", type: "uses" },
|
||||||
|
{ source: "payment-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "notification-agent", target: "queue-service", type: "uses" },
|
||||||
|
{ source: "analytics-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "db-service", type: "uses" },
|
||||||
|
{ source: "report-agent", target: "redis-service", type: "uses" },
|
||||||
|
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
|
||||||
|
{ source: "auth-service", target: "db-service", type: "depends_on" },
|
||||||
|
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
|
||||||
|
{ source: "queue-service", target: "redis-service", type: "depends_on" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const Routing = () => {
|
export const Routing = () => {
|
||||||
return (
|
return (
|
||||||
@@ -17,15 +119,94 @@ export const Routing = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
<Route element={<DefaultLayout />}>
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
|
||||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route element={<DefaultLayout />}>
|
||||||
|
{/* Routes requiring 'view' permission */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<TemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/logs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<LogsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/graphs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<GraphsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/:agentLabel"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<AgentDashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Routes requiring 'manage_agent' permission */}
|
||||||
|
<Route
|
||||||
|
path="/add-agents"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireManageAgent>
|
||||||
|
<AddAgentsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/registration"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireManageAgent>
|
||||||
|
<RegistrationTokenPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/templates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<TemplatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/IDE"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireView>
|
||||||
|
<IDEPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin route requiring 'admin' permission */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<AdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/test" element={<TestPage />} />
|
||||||
|
|
||||||
|
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
FaUsers,
|
||||||
|
FaShieldAlt,
|
||||||
|
FaSpinner,
|
||||||
|
FaExclamationCircle,
|
||||||
|
FaPlus,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { useAdminStore } from "./store/useAdminStore";
|
||||||
|
import { UserCard } from "./components/UserCard";
|
||||||
|
import { CreateUserModal } from "./components/CreateUserModal";
|
||||||
|
|
||||||
|
export const AdminPanel: React.FC = () => {
|
||||||
|
const users = useAdminStore((s) => s.users);
|
||||||
|
const loading = useAdminStore((s) => s.loading);
|
||||||
|
const error = useAdminStore((s) => s.error);
|
||||||
|
const fetchUsers = useAdminStore((s) => s.fetchUsers);
|
||||||
|
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeCount = users.filter((u) => u.is_active).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px", maxWidth: "900px", margin: "0 auto" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaShieldAlt size={18} style={{ color: "var(--accent-text)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Управление пользователями
|
||||||
|
</h1>
|
||||||
|
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||||
|
{loading
|
||||||
|
? "Загрузка..."
|
||||||
|
: `${activeCount} / ${users.length} активных`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPlus size={12} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "12px",
|
||||||
|
backgroundColor: "rgba(239,68,68,0.1)",
|
||||||
|
border: "1px solid rgba(239,68,68,0.3)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "var(--error-text, #ef4444)",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaExclamationCircle />
|
||||||
|
<span style={{ fontSize: "13px" }}>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && users.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "60px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSpinner
|
||||||
|
className="animate-spin"
|
||||||
|
size={24}
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users list */}
|
||||||
|
{!loading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((user) => (
|
||||||
|
<UserCard key={user.id} user={user} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && users.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "40px 0",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontSize: "14px" }}>
|
||||||
|
Нет зарегистрированных пользователей
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create user modal */}
|
||||||
|
<CreateUserModal
|
||||||
|
isOpen={showCreateModal}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const raw = localStorage.getItem("auth-storage");
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.state?.token) return `bearer ${parsed.state.token}`;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AdminUserDto {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserPayload {
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
password: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionsPayload {
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
getUsers: async (): Promise<AdminUserDto[]> => {
|
||||||
|
const res = await apiClient.get<AdminUserDto[]>("/auth/tokens", {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (payload: CreateUserPayload): Promise<void> => {
|
||||||
|
await apiClient.post("/auth/token", payload, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (login: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/auth/tokens/${login}`, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
activateUser: async (login: string): Promise<void> => {
|
||||||
|
await apiClient.post(
|
||||||
|
`/auth/users/${login}/activate`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: getAuthHeader() } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivateUser: async (login: string): Promise<void> => {
|
||||||
|
await apiClient.post(
|
||||||
|
`/auth/users/${login}/deactivate`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: getAuthHeader() } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePermissions: async (
|
||||||
|
login: string,
|
||||||
|
payload: PermissionsPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/auth/users/${login}/permissions`, payload, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
generateToken: async (label: string): Promise<string> => {
|
||||||
|
const res = await apiClient.post<{ token: string }>(
|
||||||
|
"/agents/register-token",
|
||||||
|
{ label },
|
||||||
|
{ headers: { Authorization: getAuthHeader() } },
|
||||||
|
);
|
||||||
|
return res.data.token;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FaTimes, FaPlus } from "react-icons/fa";
|
||||||
|
import { useAdminStore } from "../store/useAdminStore";
|
||||||
|
|
||||||
|
interface CreateUserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const createUser = useAdminStore((s) => s.createUser);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
login: "",
|
||||||
|
name: "",
|
||||||
|
last_name: "",
|
||||||
|
password: "",
|
||||||
|
is_active: true,
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.login || !form.password) return;
|
||||||
|
setLoading(true);
|
||||||
|
await createUser(form);
|
||||||
|
setLoading(false);
|
||||||
|
setForm({
|
||||||
|
login: "",
|
||||||
|
name: "",
|
||||||
|
last_name: "",
|
||||||
|
password: "",
|
||||||
|
is_active: true,
|
||||||
|
permission_admin: false,
|
||||||
|
permission_manage_agent: false,
|
||||||
|
permission_view: true,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
minWidth: "380px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать пользователя
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
{/* Login */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.login}
|
||||||
|
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + Last name */}
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Имя
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Фамилия
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.last_name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, last_name: e.target.value })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div style={{ paddingTop: "8px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Разрешения
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
|
||||||
|
{[
|
||||||
|
{ key: "is_active", label: "Active" },
|
||||||
|
{ key: "permission_view", label: "View" },
|
||||||
|
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||||
|
{ key: "permission_admin", label: "Admin" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={
|
||||||
|
form[key as keyof typeof form] as boolean
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, [key]: e.target.checked })
|
||||||
|
}
|
||||||
|
style={{ accentColor: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !form.login || !form.password}
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor:
|
||||||
|
loading || !form.login || !form.password
|
||||||
|
? "var(--bg-secondary)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color:
|
||||||
|
loading || !form.login || !form.password
|
||||||
|
? "var(--text-muted)"
|
||||||
|
: "var(--accent-text)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
cursor:
|
||||||
|
loading || !form.login || !form.password
|
||||||
|
? "default"
|
||||||
|
: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPlus size={12} />
|
||||||
|
{loading ? "Создание..." : "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
|
||||||
|
import type { AdminUser, PermissionKey } from "../types";
|
||||||
|
import { useAdminStore } from "../store/useAdminStore";
|
||||||
|
|
||||||
|
interface UserCardProps {
|
||||||
|
user: AdminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions: { key: PermissionKey; label: string }[] = [
|
||||||
|
{ key: "permission_view", label: "View" },
|
||||||
|
{ key: "permission_manage_agent", label: "Manage Agent" },
|
||||||
|
{ key: "permission_admin", label: "Admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
||||||
|
const users = useAdminStore((s) => s.users);
|
||||||
|
const toggleActive = useAdminStore((s) => s.toggleActive);
|
||||||
|
const togglePermission = useAdminStore((s) => s.togglePermission);
|
||||||
|
const deleteUser = useAdminStore((s) => s.deleteUser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
opacity: user.is_active ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header: User info + Active toggle + Delete */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: user.is_active
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaUser size={16} style={{ color: "var(--card-bg)" }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name} {user.last_name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.login}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
{/* Active toggle */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
color: user.is_active
|
||||||
|
? "var(--success-text, #22c55e)"
|
||||||
|
: "var(--error-text, #ef4444)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.is_active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(user.id, user.login, user.is_active)}
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "22px",
|
||||||
|
borderRadius: "11px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
|
||||||
|
cursor: "pointer",
|
||||||
|
position: "relative",
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
position: "absolute",
|
||||||
|
top: "3px",
|
||||||
|
left: user.is_active ? "21px" : "3px",
|
||||||
|
transition: "left 0.2s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Удалить пользователя "${user.login}"?`)) {
|
||||||
|
deleteUser(user.id, user.login);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Удалить"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "6px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--error-text, #ef4444)";
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(239,68,68,0.1)";
|
||||||
|
e.currentTarget.style.borderColor = "rgba(239,68,68,0.3)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--text-muted)";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrash size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "16px",
|
||||||
|
paddingTop: "12px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permissions.map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => togglePermission(user.id, user.login, key, users)}
|
||||||
|
style={{
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: user[key] ? "var(--accent)" : "var(--border)",
|
||||||
|
backgroundColor: user[key] ? "var(--accent)" : "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user[key] && (
|
||||||
|
<FaCheck
|
||||||
|
size={10}
|
||||||
|
style={{ color: "var(--accent-text, #fff)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { AdminPanel } from "./AdminPanel";
|
||||||
|
export { useAdminStore } from "./store/useAdminStore";
|
||||||
|
export { adminApi } from "./api/admin.api";
|
||||||
|
export type { AdminUser } from "./types";
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { AdminUser, PermissionKey } from "../types";
|
||||||
|
import { adminApi } from "../api/admin.api";
|
||||||
|
import type { CreateUserPayload } from "../api/admin.api";
|
||||||
|
|
||||||
|
interface AdminState {
|
||||||
|
users: AdminUser[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchUsers: () => Promise<void>;
|
||||||
|
createUser: (payload: CreateUserPayload) => Promise<void>;
|
||||||
|
deleteUser: (id: string, login: string) => Promise<void>;
|
||||||
|
toggleActive: (id: string, login: string, current: boolean) => Promise<void>;
|
||||||
|
togglePermission: (
|
||||||
|
id: string,
|
||||||
|
login: string,
|
||||||
|
permission: PermissionKey,
|
||||||
|
users: AdminUser[],
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdminStore = create<AdminState>((set, get) => ({
|
||||||
|
users: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchUsers: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getUsers();
|
||||||
|
set({
|
||||||
|
users: data.map((u) => ({
|
||||||
|
id: String(u.id),
|
||||||
|
login: u.login,
|
||||||
|
name: u.name,
|
||||||
|
last_name: u.last_name,
|
||||||
|
is_active: u.is_active,
|
||||||
|
permission_admin: u.permission_admin,
|
||||||
|
permission_manage_agent: u.permission_manage_agent,
|
||||||
|
permission_view: u.permission_view,
|
||||||
|
})),
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
set({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to fetch users",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (payload) => {
|
||||||
|
try {
|
||||||
|
await adminApi.createUser(payload);
|
||||||
|
await get().fetchUsers();
|
||||||
|
} catch (e) {
|
||||||
|
set({ error: e instanceof Error ? e.message : "Failed to create user" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (id, login) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteUser(login);
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.filter((u) => u.id !== id),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set({ error: e instanceof Error ? e.message : "Failed to delete user" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleActive: async (id, login, current) => {
|
||||||
|
try {
|
||||||
|
if (current) {
|
||||||
|
await adminApi.deactivateUser(login);
|
||||||
|
} else {
|
||||||
|
await adminApi.activateUser(login);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.map((u) =>
|
||||||
|
u.id === id ? { ...u, is_active: !current } : u,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to toggle active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePermission: async (id, login, permission, users) => {
|
||||||
|
const user = users.find((u) => u.id === id);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const newPermissions = {
|
||||||
|
is_active: user.is_active,
|
||||||
|
permission_admin:
|
||||||
|
permission === "permission_admin"
|
||||||
|
? !user.permission_admin
|
||||||
|
: user.permission_admin,
|
||||||
|
permission_manage_agent:
|
||||||
|
permission === "permission_manage_agent"
|
||||||
|
? !user.permission_manage_agent
|
||||||
|
: user.permission_manage_agent,
|
||||||
|
permission_view:
|
||||||
|
permission === "permission_view"
|
||||||
|
? !user.permission_view
|
||||||
|
: user.permission_view,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.updatePermissions(login, newPermissions);
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.map((u) =>
|
||||||
|
u.id === id
|
||||||
|
? {
|
||||||
|
...u,
|
||||||
|
[permission]: !u[permission],
|
||||||
|
}
|
||||||
|
: u,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set({
|
||||||
|
error: e instanceof Error ? e.message : "Failed to update permissions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionKey =
|
||||||
|
| "permission_admin"
|
||||||
|
| "permission_manage_agent"
|
||||||
|
| "permission_view";
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
import type {
|
||||||
|
AgentInfo,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
LogFilters,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
DeployResponse,
|
||||||
|
SystemMetrics,
|
||||||
|
} from "../types/agent.types";
|
||||||
|
import type { GraphApiResponse } from "@/modules/graph/types";
|
||||||
|
|
||||||
|
class AgentApiService {
|
||||||
|
private readonly basePath = "/agents";
|
||||||
|
private readonly authBasePath = "/auth";
|
||||||
|
private readonly logsBasePath = "/logs";
|
||||||
|
|
||||||
|
async getAgents(): Promise<AgentInfo[]> {
|
||||||
|
const response = await apiClient.get<AgentInfo[]>(this.basePath);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/tokens`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: TokenCreate): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/token`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(login: string): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMyAccount(): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.authBasePath}/token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
|
||||||
|
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
|
||||||
|
params: {
|
||||||
|
level: filters?.level || undefined,
|
||||||
|
service: filters?.service || undefined,
|
||||||
|
agent: filters?.agent || undefined,
|
||||||
|
date_from: filters?.date_from || undefined,
|
||||||
|
date_to: filters?.date_to || undefined,
|
||||||
|
limit: filters?.limit ?? 100,
|
||||||
|
offset: filters?.offset ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(response.data)) {
|
||||||
|
console.error(
|
||||||
|
"[Logs] Unexpected response format:",
|
||||||
|
typeof response.data,
|
||||||
|
response.data,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertLog(entry: InsertLogRequest): Promise<void> {
|
||||||
|
await apiClient.post(this.logsBasePath, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
|
||||||
|
await apiClient.post(`${this.logsBasePath}/batch`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctAgents(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/agents`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctLevels(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/levels`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDistinctServices(): Promise<string[]> {
|
||||||
|
const response = await apiClient.get<string[]>(
|
||||||
|
`${this.logsBasePath}/services`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// User management methods
|
||||||
|
async getUserByLogin(login: string): Promise<TokenUser> {
|
||||||
|
const response = await apiClient.get<TokenUser>(
|
||||||
|
`${this.authBasePath}/users/${login}`,
|
||||||
|
);
|
||||||
|
if (!response.data || typeof response.data !== "object") {
|
||||||
|
throw new Error(`User not found: ${login}`);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInactiveUsers(): Promise<TokenUser[]> {
|
||||||
|
const response = await apiClient.get<TokenUser[]>(
|
||||||
|
`${this.authBasePath}/users/inactive`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(login: string, data: TokenUpdate): Promise<void> {
|
||||||
|
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserPermissions(
|
||||||
|
login: string,
|
||||||
|
data: TokenUpdatePermissions,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.put(
|
||||||
|
`${this.authBasePath}/users/${login}/permissions`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUserPassword(
|
||||||
|
login: string,
|
||||||
|
data: TokenPasswordReset,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateUser(login: string): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateUser(login: string): Promise<void> {
|
||||||
|
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegistrationToken(
|
||||||
|
data: RegistrationRequest,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const response = await apiClient.post<Record<string, string>>(
|
||||||
|
`${this.basePath}/register-token`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
|
||||||
|
const response = await apiClient.post<DeployResponse>(
|
||||||
|
`${this.basePath}/deploy`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemMetrics(): Promise<SystemMetrics[]> {
|
||||||
|
const response = await apiClient.get<SystemMetrics[]>(
|
||||||
|
`${this.basePath}/system-metrics`,
|
||||||
|
);
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGraph(): Promise<GraphApiResponse> {
|
||||||
|
const response = await apiClient.get<GraphApiResponse>("/graph");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentApiService = new AgentApiService();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { agentApiService } from "../api/agent.api.service";
|
||||||
|
import type { AgentInfo } from "../types/agent.types";
|
||||||
|
|
||||||
|
interface UseAgentsResult {
|
||||||
|
agents: AgentInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgents(): UseAgentsResult {
|
||||||
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAgents = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await agentApiService.getAgents();
|
||||||
|
setAgents(data);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to fetch agents";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgents();
|
||||||
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
return { agents, isLoading, error, refetch: fetchAgents };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export { SSHAgentForm } from "./ui/SSHAgentForm";
|
||||||
|
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
|
||||||
|
|
||||||
|
export { useAgents } from "./hooks/useAgents.hook";
|
||||||
|
|
||||||
|
export { agentApiService } from "./api/agent.api.service";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AgentInfo,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
TokenCreate,
|
||||||
|
TokenUser,
|
||||||
|
LogEntry,
|
||||||
|
InsertLogRequest,
|
||||||
|
InsertLogsRequest,
|
||||||
|
LogFilters,
|
||||||
|
TokenUpdate,
|
||||||
|
TokenUpdatePermissions,
|
||||||
|
TokenPasswordReset,
|
||||||
|
RegistrationRequest,
|
||||||
|
DeployResult,
|
||||||
|
DeployAgentsRequest,
|
||||||
|
AgentDeployConfig,
|
||||||
|
DeployResponse,
|
||||||
|
} from "./types/agent.types";
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type LogLevel = "info" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
|
interface LogFilterState {
|
||||||
|
searchQuery: string;
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
selectedLogLevel: LogLevel | null;
|
||||||
|
selectedService: string;
|
||||||
|
selectedAgent: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setStartDate: (date: Date | null) => void;
|
||||||
|
setEndDate: (date: Date | null) => void;
|
||||||
|
setSelectedLogLevel: (level: LogLevel | null) => void;
|
||||||
|
setSelectedService: (service: string) => void;
|
||||||
|
setSelectedAgent: (agent: string) => void;
|
||||||
|
setLimit: (limit: number) => void;
|
||||||
|
setOffset: (offset: number) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
getFilters: () => {
|
||||||
|
level?: string;
|
||||||
|
service?: string;
|
||||||
|
agent?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
||||||
|
searchQuery: "",
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
selectedLogLevel: null,
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
setStartDate: (date) => set({ startDate: date }),
|
||||||
|
setEndDate: (date) => set({ endDate: date }),
|
||||||
|
setSelectedLogLevel: (level) => set({ selectedLogLevel: level }),
|
||||||
|
setSelectedService: (service) => set({ selectedService: service }),
|
||||||
|
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
||||||
|
setLimit: (limit) => set({ limit }),
|
||||||
|
setOffset: (offset) => set({ offset }),
|
||||||
|
|
||||||
|
resetFilters: () => {
|
||||||
|
set({
|
||||||
|
searchQuery: "",
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
selectedLogLevel: null,
|
||||||
|
selectedService: "",
|
||||||
|
selectedAgent: "",
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilters: () => {
|
||||||
|
const {
|
||||||
|
selectedLogLevel,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = get();
|
||||||
|
return {
|
||||||
|
level: selectedLogLevel || undefined,
|
||||||
|
service: selectedService || undefined,
|
||||||
|
agent: selectedAgent || undefined,
|
||||||
|
date_from: startDate ? startDate.toISOString() : undefined,
|
||||||
|
date_to: endDate ? endDate.toISOString() : undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
export interface AgentInfo {
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
services: string[];
|
||||||
|
connected_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
last_name: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenCreate {
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
password: string;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUser {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
last_name: string;
|
||||||
|
permission_admin: boolean;
|
||||||
|
permission_manage_agent: boolean;
|
||||||
|
permission_view: boolean;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
Agent: string;
|
||||||
|
Level: string;
|
||||||
|
Message: string;
|
||||||
|
Service: string;
|
||||||
|
Timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertLogRequest {
|
||||||
|
agent: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
service: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertLogsRequest {
|
||||||
|
logs: InsertLogRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilters {
|
||||||
|
level?: string | string[];
|
||||||
|
service?: string;
|
||||||
|
agent?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUpdate {
|
||||||
|
name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUpdatePermissions {
|
||||||
|
is_active?: boolean;
|
||||||
|
permission_admin?: boolean;
|
||||||
|
permission_manage_agent?: boolean;
|
||||||
|
permission_view?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPasswordReset {
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationRequest {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployResult {
|
||||||
|
agent_label: string;
|
||||||
|
error?: string;
|
||||||
|
ip: string;
|
||||||
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployAgentsRequest {
|
||||||
|
servers: AgentDeployConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDeployConfig {
|
||||||
|
agentLabel: string;
|
||||||
|
authMethod: "key" | "password";
|
||||||
|
deployType: "docker" | "binary";
|
||||||
|
ip: string;
|
||||||
|
password?: string;
|
||||||
|
port?: number;
|
||||||
|
sshKey?: string;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployResponse {
|
||||||
|
message?: string;
|
||||||
|
results: DeployResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemMetrics {
|
||||||
|
connected_at: string;
|
||||||
|
cpu_percent: number;
|
||||||
|
disk_percent: number;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
memory_percent: number;
|
||||||
|
network_rx_bytes: number;
|
||||||
|
network_tx_bytes: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
FiSearch,
|
||||||
|
FiX,
|
||||||
|
FiFilter,
|
||||||
|
FiCalendar,
|
||||||
|
FiTag,
|
||||||
|
FiCheck,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
|
||||||
|
|
||||||
|
const logLevelColors: Record<
|
||||||
|
LogLevel,
|
||||||
|
{ bg: string; text: string; border: string }
|
||||||
|
> = {
|
||||||
|
info: {
|
||||||
|
bg: "rgba(59, 130, 246, 0.1)",
|
||||||
|
text: "#3b82f6",
|
||||||
|
border: "rgba(59, 130, 246, 0.3)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "rgba(245, 158, 11, 0.1)",
|
||||||
|
text: "#f59e0b",
|
||||||
|
border: "rgba(245, 158, 11, 0.3)",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: "var(--error-bg)",
|
||||||
|
text: "var(--error-text)",
|
||||||
|
border: "var(--error-border)",
|
||||||
|
},
|
||||||
|
fatal: {
|
||||||
|
bg: "rgba(168, 85, 247, 0.1)",
|
||||||
|
text: "#a855f7",
|
||||||
|
border: "rgba(168, 85, 247, 0.3)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LogFiltersProps {
|
||||||
|
onApply: () => void;
|
||||||
|
availableServices: string[];
|
||||||
|
availableAgents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||||
|
onApply,
|
||||||
|
availableServices,
|
||||||
|
availableAgents,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
selectedLogLevel,
|
||||||
|
selectedService,
|
||||||
|
selectedAgent,
|
||||||
|
setSearchQuery,
|
||||||
|
setStartDate,
|
||||||
|
setEndDate,
|
||||||
|
setSelectedLogLevel,
|
||||||
|
setSelectedService,
|
||||||
|
setSelectedAgent,
|
||||||
|
resetFilters,
|
||||||
|
} = useLogFilterStore();
|
||||||
|
|
||||||
|
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||||
|
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
|
||||||
|
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
|
||||||
|
const [localService, setLocalService] = useState(selectedService);
|
||||||
|
const [localAgent, setLocalAgent] = useState(selectedAgent);
|
||||||
|
const [localLevel, setLocalLevel] = useState<LogLevel | null>(
|
||||||
|
selectedLogLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSearchQuery(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStartDate(startDate);
|
||||||
|
}, [startDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalEndDate(endDate);
|
||||||
|
}, [endDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalService(selectedService);
|
||||||
|
}, [selectedService]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalAgent(selectedAgent);
|
||||||
|
}, [selectedAgent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalLevel(selectedLogLevel);
|
||||||
|
}, [selectedLogLevel]);
|
||||||
|
|
||||||
|
const handleApply = useCallback(() => {
|
||||||
|
setSearchQuery(localSearchQuery);
|
||||||
|
setStartDate(localStartDate);
|
||||||
|
setEndDate(localEndDate);
|
||||||
|
setSelectedLogLevel(localLevel);
|
||||||
|
setSelectedService(localService);
|
||||||
|
setSelectedAgent(localAgent);
|
||||||
|
onApply();
|
||||||
|
}, [
|
||||||
|
localSearchQuery,
|
||||||
|
localStartDate,
|
||||||
|
localEndDate,
|
||||||
|
localLevel,
|
||||||
|
localService,
|
||||||
|
localAgent,
|
||||||
|
onApply,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setLocalEndDate(null);
|
||||||
|
setLocalLevel(null);
|
||||||
|
setLocalService("");
|
||||||
|
setLocalAgent("");
|
||||||
|
resetFilters();
|
||||||
|
onApply();
|
||||||
|
}, [resetFilters, onApply]);
|
||||||
|
|
||||||
|
const getActiveFiltersCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
if (searchQuery) count++;
|
||||||
|
if (startDate) count++;
|
||||||
|
if (endDate) count++;
|
||||||
|
if (selectedService) count++;
|
||||||
|
if (selectedAgent) count++;
|
||||||
|
if (selectedLogLevel) count++;
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date | null) => {
|
||||||
|
if (!date) return null;
|
||||||
|
return date.toLocaleDateString("ru-RU");
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeFiltersCount = getActiveFiltersCount();
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
...inputStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FiFilter size={14} style={{ color: "var(--accent)" }} />
|
||||||
|
<h3
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Фильтры логов
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Активно: {activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<FiSearch
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "10px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localSearchQuery}
|
||||||
|
onChange={(e) => setLocalSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск по сообщению..."
|
||||||
|
style={{ ...inputStyle, paddingLeft: "32px" }}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Select */}
|
||||||
|
<select
|
||||||
|
value={localService}
|
||||||
|
onChange={(e) => setLocalService(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">Все сервисы</option>
|
||||||
|
{availableServices.map((service) => (
|
||||||
|
<option key={service} value={service}>
|
||||||
|
{service}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Agent Select */}
|
||||||
|
<select
|
||||||
|
value={localAgent}
|
||||||
|
onChange={(e) => setLocalAgent(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">Все агенты</option>
|
||||||
|
{availableAgents.map((agent) => (
|
||||||
|
<option key={agent} value={agent}>
|
||||||
|
{agent}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date Range */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={
|
||||||
|
localStartDate ? localStartDate.toISOString().split("T")[0] : ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalStartDate(
|
||||||
|
e.target.value ? new Date(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, minWidth: 0 }}
|
||||||
|
placeholder="Дата от"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={
|
||||||
|
localEndDate ? localEndDate.toISOString().split("T")[0] : ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalEndDate(
|
||||||
|
e.target.value ? new Date(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ ...inputStyle, minWidth: 0 }}
|
||||||
|
placeholder="Дата до"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Levels */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Уровень логов
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
|
||||||
|
(level) => {
|
||||||
|
const isSelected = localLevel === level;
|
||||||
|
const colors = logLevelColors[level];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => setLocalLevel(isSelected ? null : level)}
|
||||||
|
className="px-3 py-2 rounded-lg text-xs font-medium transition-all border flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? colors.bg : "transparent",
|
||||||
|
color: isSelected ? colors.text : "var(--text-secondary)",
|
||||||
|
borderColor: isSelected ? colors.border : "var(--border)",
|
||||||
|
minHeight: "36px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (isSelected) {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.text;
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
} else {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"rgba(128, 128, 128, 0.08)";
|
||||||
|
e.currentTarget.style.color = "var(--text-primary)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isSelected
|
||||||
|
? colors.bg
|
||||||
|
: "transparent";
|
||||||
|
e.currentTarget.style.color = isSelected
|
||||||
|
? colors.text
|
||||||
|
: "var(--text-secondary)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<FiCheck size={10} className="inline mr-1" />
|
||||||
|
)}
|
||||||
|
{level.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-primary)",
|
||||||
|
color: "var(--button-primary-text)",
|
||||||
|
minHeight: "44px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCheck size={14} />
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
minHeight: "44px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={14} />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-4 pt-4 border-t"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FiFilter size={10} style={{ color: "var(--accent)" }} />
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Активные фильтры:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{searchQuery && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSearch size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Поиск: {searchQuery}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalSearchQuery("");
|
||||||
|
setSearchQuery("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedService && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Сервис: {selectedService}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalService("");
|
||||||
|
setSelectedService("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedLogLevel &&
|
||||||
|
(() => {
|
||||||
|
const colors = logLevelColors[selectedLogLevel];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} style={{ color: colors.text }} />
|
||||||
|
<span style={{ color: colors.text }}>
|
||||||
|
Уровень: {selectedLogLevel.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalLevel(null);
|
||||||
|
setSelectedLogLevel(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: colors.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{selectedAgent && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTag size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
Агент: {selectedAgent}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalAgent("");
|
||||||
|
setSelectedAgent("");
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{startDate && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCalendar size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
С: {formatDate(startDate)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalStartDate(null);
|
||||||
|
setStartDate(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endDate && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiCalendar size={10} />
|
||||||
|
<span style={{ color: "var(--text-primary)" }}>
|
||||||
|
По: {formatDate(endDate)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalEndDate(null);
|
||||||
|
setEndDate(null);
|
||||||
|
onApply();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiX size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FiPlus,
|
FiPlus,
|
||||||
FiTrash2,
|
FiTrash2,
|
||||||
FiSettings,
|
FiSettings,
|
||||||
|
FiLink,
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { SiDocker } from "react-icons/si";
|
import { SiDocker } from "react-icons/si";
|
||||||
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
||||||
@@ -20,8 +21,10 @@ interface ExtraField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHAgentConfig {
|
export interface SSHAgentConfig {
|
||||||
|
agentLabel: string;
|
||||||
user: string;
|
user: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
port: number;
|
||||||
authMethod: AuthMethod;
|
authMethod: AuthMethod;
|
||||||
sshKey?: string;
|
sshKey?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -189,11 +192,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "20px" }}>
|
<div style={{ display: "grid", gap: "20px" }}>
|
||||||
{/* User и IP */}
|
{/* Agent Label */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<FiServer size={14} />
|
||||||
|
Метка агента *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.agentLabel}
|
||||||
|
onChange={(e) => handleChange("agentLabel", e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="production-server-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User, IP и Port */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -238,6 +261,31 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
placeholder="192.168.1.1"
|
placeholder="192.168.1.1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||||
|
>
|
||||||
|
<FiLink size={14} />
|
||||||
|
Порт *
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("port", parseInt(e.target.value) || 22)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
style={inputBaseStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Метод аутентификации */}
|
{/* Метод аутентификации */}
|
||||||
@@ -457,7 +505,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (data: RegisterData): Promise<LoginResponse> => {
|
const register = async (
|
||||||
const response = await apiClient.post<LoginResponse>("/auth/register", {
|
data: RegisterData,
|
||||||
login: data.login,
|
): Promise<Record<string, string>> => {
|
||||||
password: data.password,
|
const response = await apiClient.post<Record<string, string>>(
|
||||||
name: data.firstName,
|
"/auth/register",
|
||||||
last_name: data.lastName,
|
{
|
||||||
});
|
login: data.login,
|
||||||
|
password: data.password,
|
||||||
|
name: data.firstName,
|
||||||
|
last_name: data.lastName,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,9 +67,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
register: async (data: RegisterData) => {
|
register: async (data: RegisterData) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await register(data);
|
await register(data);
|
||||||
const user = mapResponseToUser(response);
|
// После регистрации пользователь не авторизуется автоматически
|
||||||
set({ user, token: response.token, isLoading: false });
|
// Нужно войти через /auth/login
|
||||||
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FaPlus } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface AddWidgetButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddWidgetButton: React.FC<AddWidgetButtonProps> = ({
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full py-1.5 bg-tertiary hover:bg-tertiary/70 rounded-lg border border-primary transition-colors flex items-center justify-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<FaPlus size={10} className="text-tertiary" />
|
||||||
|
<span className="text-[10px] text-secondary">Добавить график</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import type { ChartType } from "../types";
|
||||||
|
|
||||||
|
interface AddWidgetModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onAdd: (data: { type: ChartType; title: string; dataKey: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddWidgetModal: React.FC<AddWidgetModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [type, setType] = useState<ChartType>("line");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [dataKey, setDataKey] = useState("requests");
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
onAdd({ type, title: title.trim(), dataKey });
|
||||||
|
setTitle("");
|
||||||
|
setType("line");
|
||||||
|
setDataKey("requests");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-primary mb-3">
|
||||||
|
Добавить график
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Тип
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
||||||
|
type === t
|
||||||
|
? "bg-accent-primary text-white"
|
||||||
|
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "line" && "📈"}
|
||||||
|
{t === "bar" && "📊"}
|
||||||
|
{t === "area" && "📉"}
|
||||||
|
{t === "pie" && "🥧"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Название"
|
||||||
|
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// modules/dashboard/components/ChartWidget.tsx
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
FaChartLine,
|
||||||
|
FaChartBar,
|
||||||
|
FaChartArea,
|
||||||
|
FaChartPie,
|
||||||
|
FaCog,
|
||||||
|
FaEye,
|
||||||
|
FaEyeSlash,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartWidget as ChartWidgetType, MetricData } from "../types";
|
||||||
|
|
||||||
|
interface ChartWidgetProps {
|
||||||
|
widget: ChartWidgetType;
|
||||||
|
data: MetricData[];
|
||||||
|
onEdit: () => void;
|
||||||
|
onToggleVisibility: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все возможные уровни логов (метрики)
|
||||||
|
const METRICS = ["INFO", "WARN", "ERROR", "DEBUG"];
|
||||||
|
|
||||||
|
// Цвета для каждой метрики
|
||||||
|
const METRIC_COLORS: Record<string, string> = {
|
||||||
|
INFO: "#10b981", // зеленый
|
||||||
|
WARN: "#f59e0b", // оранжевый
|
||||||
|
ERROR: "#ef4444", // красный
|
||||||
|
DEBUG: "#3b82f6", // синий
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||||
|
widget,
|
||||||
|
data,
|
||||||
|
onEdit,
|
||||||
|
onToggleVisibility,
|
||||||
|
}) => {
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-[10px] text-tertiary">Нет данных</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedData = data.map((point) => {
|
||||||
|
const normalized: MetricData = { timestamp: point.timestamp };
|
||||||
|
METRICS.forEach((metric) => {
|
||||||
|
normalized[metric] = point[metric] || 0;
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
data: normalizedData,
|
||||||
|
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (widget.type) {
|
||||||
|
case "line":
|
||||||
|
return (
|
||||||
|
<LineChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Line
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={METRIC_COLORS[metric]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "bar":
|
||||||
|
return (
|
||||||
|
<BarChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Bar
|
||||||
|
key={metric}
|
||||||
|
dataKey={metric}
|
||||||
|
fill={METRIC_COLORS[metric]}
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "area":
|
||||||
|
return (
|
||||||
|
<AreaChart {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
stroke="#64748b"
|
||||||
|
tick={{ fontSize: 9 }}
|
||||||
|
interval={Math.floor(normalizedData.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<Area
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={METRIC_COLORS[metric]}
|
||||||
|
fill={METRIC_COLORS[metric]}
|
||||||
|
fillOpacity={0.2}
|
||||||
|
name={metric}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "pie":
|
||||||
|
// Для круговой диаграммы берем последнюю точку
|
||||||
|
const lastPoint = normalizedData[normalizedData.length - 1];
|
||||||
|
const pieData = METRICS.map((metric) => ({
|
||||||
|
name: metric,
|
||||||
|
value: lastPoint[metric] || 0,
|
||||||
|
})).filter((item) => Number(item.value) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={55}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={METRIC_COLORS[entry.name]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "#1e293b",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "10px" }}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (widget.type) {
|
||||||
|
case "line":
|
||||||
|
return <FaChartLine size={10} />;
|
||||||
|
case "bar":
|
||||||
|
return <FaChartBar size={10} />;
|
||||||
|
case "area":
|
||||||
|
return <FaChartArea size={10} />;
|
||||||
|
case "pie":
|
||||||
|
return <FaChartPie size={10} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className={`bg-secondary rounded-lg border border-primary p-2 transition-all ${!widget.visible ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1 px-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-tertiary">{getIcon()}</span>
|
||||||
|
<h3 className="text-[11px] font-medium text-primary">
|
||||||
|
{widget.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={onToggleVisibility}
|
||||||
|
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
||||||
|
title={widget.visible ? "Скрыть" : "Показать"}
|
||||||
|
>
|
||||||
|
{widget.visible ? (
|
||||||
|
<FaEye size={9} className="text-tertiary" />
|
||||||
|
) : (
|
||||||
|
<FaEyeSlash size={9} className="text-tertiary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
<FaCog size={9} className="text-tertiary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-40">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// modules/dashboard/components/WidgetSettings.tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartType, ChartWidget } from "../types";
|
||||||
|
|
||||||
|
interface WidgetSettingsProps {
|
||||||
|
widget: ChartWidget;
|
||||||
|
onUpdate: (widget: ChartWidget) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WidgetSettings: React.FC<WidgetSettingsProps> = ({
|
||||||
|
widget,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [type, setType] = useState<ChartType>(widget.type);
|
||||||
|
const [title, setTitle] = useState(widget.title);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate({ ...widget, type, title });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-primary mb-3">
|
||||||
|
Настройки графика
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">Тип</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
||||||
|
type === t
|
||||||
|
? "bg-accent-primary text-white"
|
||||||
|
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "line" && "📈"}
|
||||||
|
{t === "bar" && "📊"}
|
||||||
|
{t === "area" && "📉"}
|
||||||
|
{t === "pie" && "🥧"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] text-secondary mb-1">
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="px-2 py-1 bg-red-500/10 text-red-500 rounded text-[10px] hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ChartType, MetricData } from "../types";
|
||||||
|
|
||||||
|
interface DashboardChartProps {
|
||||||
|
title: string;
|
||||||
|
type: ChartType;
|
||||||
|
data: MetricData[];
|
||||||
|
dataKeys: string[];
|
||||||
|
colors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
|
||||||
|
|
||||||
|
export const DashboardChart: React.FC<DashboardChartProps> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
dataKeys,
|
||||||
|
colors = COLORS,
|
||||||
|
}) => {
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Нет данных
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
data,
|
||||||
|
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const axisStyle = {
|
||||||
|
stroke: "var(--text-secondary)",
|
||||||
|
tick: { fontSize: 10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "11px",
|
||||||
|
},
|
||||||
|
labelStyle: { color: "var(--text-primary)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "pie") {
|
||||||
|
// Если данные уже в формате { name, value } — используем напрямую
|
||||||
|
const isPieFormat =
|
||||||
|
data.length > 0 && "name" in data[0] && "value" in data[0];
|
||||||
|
|
||||||
|
const pieData = isPieFormat
|
||||||
|
? data
|
||||||
|
: data.map((point, i) => ({
|
||||||
|
name: dataKeys[i % dataKeys.length],
|
||||||
|
value: point[dataKeys[i % dataKeys.length]] || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={60}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip {...tooltipStyle} />
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: "11px" }}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartComponent =
|
||||||
|
type === "line" ? LineChart : type === "area" ? AreaChart : BarChart;
|
||||||
|
const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartComponent {...commonProps}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="timestamp"
|
||||||
|
{...axisStyle}
|
||||||
|
interval={Math.floor(data.length / 5)}
|
||||||
|
/>
|
||||||
|
<YAxis {...axisStyle} width={35} />
|
||||||
|
<Tooltip {...tooltipStyle} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||||
|
{dataKeys.map((key, i) => (
|
||||||
|
<DataComponent
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stroke={colors[i % colors.length]}
|
||||||
|
fill={colors[i % colors.length]}
|
||||||
|
fillOpacity={type === "area" ? 0.2 : undefined}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name={key}
|
||||||
|
radius={type === "bar" ? [2, 2, 0, 0] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChartComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div style={{ height: 180 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// modules/dashboard/Dashboard.tsx
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useDashboardStore } from "./store/dashboard.store";
|
||||||
|
import { useAuthStore } from "../auth/store/useAuthStore";
|
||||||
|
import { ChartWidget } from "./components/chart,widget";
|
||||||
|
import { AddWidgetButton } from "./components/add.widget.button";
|
||||||
|
import { AddWidgetModal } from "./components/add.widget.modal";
|
||||||
|
import { WidgetSettings } from "./components/chart.settings";
|
||||||
|
import { useWidgets } from "./hooks/use.widget";
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const { chartData, loading, error, fetchMetrics, clearData } =
|
||||||
|
useDashboardStore();
|
||||||
|
// const { servicesQueryParams } = useAgentStore();
|
||||||
|
const intervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
|
// Первичная загрузка (не latest)
|
||||||
|
// const fetchPrimaryData = () => {
|
||||||
|
// fetchMetrics(false, token || "", servicesQueryParams, { since: "10m" });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// Периодическое обновление (latest)
|
||||||
|
// const fetchLatestData = () => {
|
||||||
|
// fetchMetrics(true, token || "", servicesQueryParams);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// fetchPrimaryData();
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// intervalRef.current = window.setInterval(() => {
|
||||||
|
// fetchLatestData();
|
||||||
|
// }, 30000);
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// if (intervalRef.current) {
|
||||||
|
// window.clearInterval(intervalRef.current);
|
||||||
|
// }
|
||||||
|
// clearData();
|
||||||
|
// };
|
||||||
|
// }, [servicesQueryParams]);
|
||||||
|
|
||||||
|
const { widgets, addWidget, updateWidget, removeWidget, toggleVisibility } =
|
||||||
|
useWidgets();
|
||||||
|
const [editingWidget, setEditingWidget] = useState<any>(null);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const visibleWidgets = widgets.filter((w) => w.visible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
{loading && chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<span className="text-[10px] text-red-500">{error}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
|
||||||
|
{visibleWidgets.map((widget) => (
|
||||||
|
<ChartWidget
|
||||||
|
key={widget.id}
|
||||||
|
widget={widget}
|
||||||
|
data={chartData}
|
||||||
|
onEdit={() => setEditingWidget(widget)}
|
||||||
|
onToggleVisibility={() => toggleVisibility(widget.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddWidgetButton onClick={() => setIsAdding(true)} />
|
||||||
|
|
||||||
|
<AddWidgetModal
|
||||||
|
isOpen={isAdding}
|
||||||
|
onAdd={addWidget}
|
||||||
|
onClose={() => setIsAdding(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editingWidget && (
|
||||||
|
<WidgetSettings
|
||||||
|
widget={editingWidget}
|
||||||
|
onUpdate={updateWidget}
|
||||||
|
onRemove={() => removeWidget(editingWidget.id)}
|
||||||
|
onClose={() => setEditingWidget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ChartType, ChartWidget } from "../types";
|
||||||
|
|
||||||
|
const initialWidgets: ChartWidget[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "line",
|
||||||
|
title: "Линии",
|
||||||
|
dataKey: "chart-line",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "bar",
|
||||||
|
title: "Столбцы",
|
||||||
|
dataKey: "chart-bar",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "area",
|
||||||
|
title: "Закрашенные линии",
|
||||||
|
dataKey: "chart-area",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "pie",
|
||||||
|
title: "Круговая диаграмма",
|
||||||
|
dataKey: "chart-pie",
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useWidgets = () => {
|
||||||
|
const [widgets, setWidgets] = useState<ChartWidget[]>(initialWidgets);
|
||||||
|
|
||||||
|
const addWidget = (data: {
|
||||||
|
type: ChartType;
|
||||||
|
title: string;
|
||||||
|
dataKey: string;
|
||||||
|
}) => {
|
||||||
|
const newWidget: ChartWidget = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
...data,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
setWidgets([...widgets, newWidget]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWidget = (updated: ChartWidget) => {
|
||||||
|
setWidgets(widgets.map((w) => (w.id === updated.id ? updated : w)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeWidget = (id: string) => {
|
||||||
|
setWidgets(widgets.filter((w) => w.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVisibility = (id: string) => {
|
||||||
|
setWidgets(
|
||||||
|
widgets.map((w) => (w.id === id ? { ...w, visible: !w.visible } : w)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
widgets,
|
||||||
|
addWidget,
|
||||||
|
updateWidget,
|
||||||
|
removeWidget,
|
||||||
|
toggleVisibility,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { apiService } from "@/shared/api/api.service";
|
||||||
|
import type { MetricData } from "../types";
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
chartData: MetricData[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchMetrics: (
|
||||||
|
isLatest: boolean,
|
||||||
|
token: string,
|
||||||
|
queryParams?: string,
|
||||||
|
extraParams?: Record<string, string>,
|
||||||
|
) => Promise<void>;
|
||||||
|
clearData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDashboardStore = create<DashboardState>((set, get) => {
|
||||||
|
const convertPrimaryData = (response: any) => {
|
||||||
|
set((state) => {
|
||||||
|
if (!response.intervals || !Array.isArray(response.intervals))
|
||||||
|
return { chartData: state.chartData };
|
||||||
|
|
||||||
|
const newData = [...state.chartData];
|
||||||
|
|
||||||
|
response.intervals.forEach((interval: any) => {
|
||||||
|
const newPoint: MetricData = {
|
||||||
|
timestamp: new Date(interval.timestamp).toLocaleTimeString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (interval.group_by && Array.isArray(interval.group_by)) {
|
||||||
|
interval.group_by.forEach((item: any) => {
|
||||||
|
newPoint[item.value] = item.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newData.push(newPoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chartData: newData.slice(-20) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertSingleData = (response: any) => {
|
||||||
|
set((state) => {
|
||||||
|
const newPoint: MetricData = {
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
response.forEach((item: any) => {
|
||||||
|
newPoint[item.value] = item.count;
|
||||||
|
});
|
||||||
|
} else if (response.groupBy && Array.isArray(response.groupBy)) {
|
||||||
|
response.groupBy.forEach((item: any) => {
|
||||||
|
newPoint[item.value] = item.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedData = [...state.chartData, newPoint].slice(-20);
|
||||||
|
return { chartData: updatedData };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMetrics = async (
|
||||||
|
isLatest: boolean,
|
||||||
|
token: string,
|
||||||
|
queryParams?: string,
|
||||||
|
extraParams?: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint = isLatest
|
||||||
|
? "logs/aggregations/latest"
|
||||||
|
: "logs/aggregations";
|
||||||
|
|
||||||
|
// Если есть queryParams, добавляем его к эндпоинту
|
||||||
|
if (queryParams && queryParams.trim() !== "") {
|
||||||
|
endpoint = `${endpoint}?${queryParams}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
agg: "count",
|
||||||
|
groupby: "level",
|
||||||
|
...extraParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiService.get<any>(endpoint, {
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
Authorization: `bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
if (isLatest) {
|
||||||
|
convertSingleData(result);
|
||||||
|
} else {
|
||||||
|
convertPrimaryData(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch ${isLatest ? "latest" : "primary"} metrics:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : "Ошибка запроса",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearData = () => {
|
||||||
|
set({ chartData: [], error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
chartData: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fetchMetrics,
|
||||||
|
clearData,
|
||||||
|
setChartData: (data: MetricData[]) =>
|
||||||
|
set({ chartData: data, loading: false }),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type ChartType = "line" | "bar" | "area" | "pie";
|
||||||
|
|
||||||
|
export interface ChartWidget {
|
||||||
|
id: string;
|
||||||
|
type: ChartType;
|
||||||
|
title: string;
|
||||||
|
dataKey: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricData {
|
||||||
|
timestamp: string;
|
||||||
|
[key: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsItem {
|
||||||
|
label: string;
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
import type {
|
||||||
|
GraphData,
|
||||||
|
GraphNode,
|
||||||
|
GraphLink,
|
||||||
|
ContextMenuState,
|
||||||
|
} from "./types";
|
||||||
|
import { useGraphStore } from "./store/useGraphStore";
|
||||||
|
import {
|
||||||
|
ForceGraph,
|
||||||
|
GraphControls,
|
||||||
|
GraphContextMenu,
|
||||||
|
GraphStatusBar,
|
||||||
|
GraphStats,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
interface GraphProps {
|
||||||
|
initialData?: GraphData;
|
||||||
|
onExport?: () => void;
|
||||||
|
onDataChange?: (data: GraphData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Graph: React.FC<GraphProps> = ({
|
||||||
|
initialData,
|
||||||
|
onExport,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const fgRef = useRef<any>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
|
||||||
|
const data = useGraphStore((s) => s.data);
|
||||||
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
const selectedNode = useGraphStore((s) => s.selectedNode);
|
||||||
|
const setData = useGraphStore((s) => s.setData);
|
||||||
|
|
||||||
|
// Инициализация данных
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) setData(initialData);
|
||||||
|
}, [initialData, setData]);
|
||||||
|
|
||||||
|
// Закрыть контекстное меню по клику вне
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeRightClick = (node: GraphNode, event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY, node, link: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || data.nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-4 h-full flex flex-col"
|
||||||
|
style={{ backgroundColor: "var(--card-bg)" }}
|
||||||
|
>
|
||||||
|
{/* Статистика сверху */}
|
||||||
|
<GraphStats data={data} />
|
||||||
|
|
||||||
|
{/* Граф */}
|
||||||
|
<div
|
||||||
|
className="flex-1 rounded-lg overflow-hidden relative mt-2"
|
||||||
|
style={{ border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<ForceGraph
|
||||||
|
ref={fgRef}
|
||||||
|
data={data}
|
||||||
|
onNodeRightClick={handleNodeRightClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GraphContextMenu
|
||||||
|
menu={contextMenu}
|
||||||
|
data={data}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки снизу */}
|
||||||
|
<GraphControls
|
||||||
|
fgRef={fgRef}
|
||||||
|
onExport={onExport}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Graph;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
import ForceGraph2D from "react-force-graph-2d";
|
||||||
|
import type { GraphData, GraphNode, GraphLink } from "../types";
|
||||||
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
|
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||||
|
|
||||||
|
interface ForceGraphProps {
|
||||||
|
data: GraphData;
|
||||||
|
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
||||||
|
({ data, onNodeRightClick }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
|
||||||
|
|
||||||
|
const highlightNodes = useGraphStore((s) => s.highlightNodes);
|
||||||
|
const highlightLinks = useGraphStore((s) => s.highlightLinks);
|
||||||
|
const selectedNode = useGraphStore((s) => s.selectedNode);
|
||||||
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
const theme = useThemeStore((s) => s.theme);
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
// Определяем цвета текста в зависимости от темы
|
||||||
|
const nodeTextColor = isDark ? "#e5e7eb" : "#1f2937";
|
||||||
|
const nodeTextLetterColor = isDark ? "#ffffff" : "#000000";
|
||||||
|
|
||||||
|
// ResizeObserver для корректного отслеживания размеров
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
setDimensions({
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeClick = useCallback((node: GraphNode) => {
|
||||||
|
const store = useGraphStore.getState();
|
||||||
|
if (store.isLinkMode) {
|
||||||
|
if (store.selectedNode === null) {
|
||||||
|
store.setSelectedNode(node);
|
||||||
|
} else if (store.selectedNode.id !== node.id) {
|
||||||
|
store.createLink(store.selectedNode.id, node.id);
|
||||||
|
store.setSelectedNode(null);
|
||||||
|
store.toggleLinkMode();
|
||||||
|
} else {
|
||||||
|
store.setSelectedNode(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeHover = (node: GraphNode | null) => {
|
||||||
|
const newHighlightNodes = new Set<string>();
|
||||||
|
const newHighlightLinks = new Set<GraphLink>();
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
newHighlightNodes.add(node.id);
|
||||||
|
data.links.forEach((link) => {
|
||||||
|
if (link.source === node.id || link.target === node.id) {
|
||||||
|
newHighlightLinks.add(link);
|
||||||
|
newHighlightNodes.add(link.source as string);
|
||||||
|
newHighlightNodes.add(link.target as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useGraphStore
|
||||||
|
.getState()
|
||||||
|
.setHighlight(newHighlightNodes, newHighlightLinks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeColor = (node: GraphNode) => {
|
||||||
|
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
|
||||||
|
|
||||||
|
if (node.type === "service" && node.status === "down") {
|
||||||
|
// Проверяем, есть ли зависимости этого сервиса, которые тоже упали
|
||||||
|
const hasDownDependency = data.links.some((link) => {
|
||||||
|
const sourceId =
|
||||||
|
typeof link.source === "object"
|
||||||
|
? (link.source as any).id
|
||||||
|
: link.source;
|
||||||
|
const targetId =
|
||||||
|
typeof link.target === "object"
|
||||||
|
? (link.target as any).id
|
||||||
|
: link.target;
|
||||||
|
|
||||||
|
if (sourceId !== node.id) return false;
|
||||||
|
|
||||||
|
const isDependency =
|
||||||
|
link.type === "dependency" || link.type === "started";
|
||||||
|
const targetIsDown = data.nodes.some(
|
||||||
|
(n) => n.id === targetId && n.status === "down",
|
||||||
|
);
|
||||||
|
|
||||||
|
return isDependency && targetIsDown;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если есть упавшая зависимость — не подсвечиваем красным
|
||||||
|
if (hasDownDependency) return "#3b82f6";
|
||||||
|
return "#ef4444";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "agent") {
|
||||||
|
// Проверяем, есть ли у агента хотя бы один упавший сервис
|
||||||
|
const hasDownService = data.nodes.some(
|
||||||
|
(n) =>
|
||||||
|
n.type === "service" &&
|
||||||
|
n.status === "down" &&
|
||||||
|
n.id.startsWith(`${node.id}-`),
|
||||||
|
);
|
||||||
|
if (hasDownService) return "#ef4444";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case "service":
|
||||||
|
return "#3b82f6";
|
||||||
|
case "agent":
|
||||||
|
return "#8b5cf6";
|
||||||
|
default:
|
||||||
|
return "#6b7280";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeSize = (node: GraphNode) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case "service":
|
||||||
|
return 3;
|
||||||
|
case "agent":
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNode = (
|
||||||
|
node: GraphNode,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
globalScale: number,
|
||||||
|
) => {
|
||||||
|
const size = getNodeSize(node);
|
||||||
|
const color = getNodeColor(node);
|
||||||
|
|
||||||
|
if (!node.x || !node.y) return;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = nodeTextLetterColor;
|
||||||
|
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
|
||||||
|
if (node.type === "service") {
|
||||||
|
ctx.fillText("S", node.x, node.y);
|
||||||
|
} else if (node.type === "agent") {
|
||||||
|
ctx.fillText("A", node.x, node.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalScale > 0.5) {
|
||||||
|
ctx.fillStyle = nodeTextColor;
|
||||||
|
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(node.name, node.x, node.y + size + 8);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEngineStop = () => {
|
||||||
|
if (typeof ref !== "function" && ref && "current" in ref && ref.current) {
|
||||||
|
ref.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full h-full relative">
|
||||||
|
<ForceGraph2D
|
||||||
|
ref={ref}
|
||||||
|
graphData={data}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
nodeCanvasObject={renderNode}
|
||||||
|
nodeLabel={(node: GraphNode) => {
|
||||||
|
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
|
||||||
|
}}
|
||||||
|
linkLabel={(link: GraphLink) => {
|
||||||
|
const sourceName =
|
||||||
|
data.nodes.find((n) => n.id === link.source)?.name || link.source;
|
||||||
|
const targetName =
|
||||||
|
data.nodes.find((n) => n.id === link.target)?.name || link.target;
|
||||||
|
return `Связь: ${sourceName} → ${targetName}\nПКМ для удаления`;
|
||||||
|
}}
|
||||||
|
linkColor={(link: any) => {
|
||||||
|
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
|
||||||
|
}}
|
||||||
|
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
|
||||||
|
linkDirectionalParticles={0}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeRightClick={onNodeRightClick}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
cooldownTicks={50}
|
||||||
|
cooldownTime={2000}
|
||||||
|
d3AlphaDecay={0.03}
|
||||||
|
d3VelocityDecay={0.4}
|
||||||
|
warmupTicks={50}
|
||||||
|
onEngineStop={handleEngineStop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ForceGraph.displayName = "ForceGraph";
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiLink, FiTrash2 } from "react-icons/fi";
|
||||||
|
import type { ContextMenuState, GraphNode, GraphData } from "../types";
|
||||||
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
|
|
||||||
|
interface GraphContextMenuProps {
|
||||||
|
menu: ContextMenuState | null;
|
||||||
|
data: GraphData;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||||
|
menu,
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const removeNode = useGraphStore((s) => s.removeNode);
|
||||||
|
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
||||||
|
const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
|
||||||
|
|
||||||
|
if (!menu) return null;
|
||||||
|
|
||||||
|
const handleDeleteNode = (node: GraphNode) => {
|
||||||
|
removeNode(node.id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLink = (node: GraphNode) => {
|
||||||
|
toggleLinkMode();
|
||||||
|
setSelectedNode(node);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed rounded-lg shadow-lg py-1 z-50"
|
||||||
|
style={{
|
||||||
|
top: menu.y,
|
||||||
|
left: menu.x,
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{menu.node && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="px-3 py-1 text-xs border-b"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menu.node.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCreateLink(menu.node!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = "var(--bg-secondary)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = "transparent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FiLink size={14} /> Создать связь
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNode(menu.node!)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
|
||||||
|
style={{ color: "#f87171" }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = "rgba(248,113,113,0.1)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.backgroundColor = "transparent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={14} /> Удалить узел
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
FiDownload,
|
||||||
|
FiZoomIn,
|
||||||
|
FiZoomOut,
|
||||||
|
FiMove,
|
||||||
|
FiLink,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
import { useGraphStore } from "../store/useGraphStore";
|
||||||
|
import type { GraphData } from "../types";
|
||||||
|
|
||||||
|
interface GraphControlsProps {
|
||||||
|
fgRef: React.RefObject<any>;
|
||||||
|
onExport?: () => void;
|
||||||
|
onDataChange?: (data: GraphData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GraphControls: React.FC<GraphControlsProps> = ({
|
||||||
|
fgRef,
|
||||||
|
onExport,
|
||||||
|
onDataChange,
|
||||||
|
}) => {
|
||||||
|
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
||||||
|
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
||||||
|
const exportData = useGraphStore((s) => s.exportData);
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
const currentZoom = fgRef.current.zoom();
|
||||||
|
fgRef.current.zoom(currentZoom * 1.2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
const currentZoom = fgRef.current.zoom();
|
||||||
|
fgRef.current.zoom(currentZoom / 1.2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFit = () => {
|
||||||
|
if (fgRef.current) {
|
||||||
|
fgRef.current.zoomToFit(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-2">
|
||||||
|
{/* Режим создания связи */}
|
||||||
|
{/* <button
|
||||||
|
onClick={toggleLinkMode}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isLinkMode ? "#22c55e" : "var(--bg-secondary)",
|
||||||
|
color: isLinkMode ? "#fff" : "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiLink />
|
||||||
|
<span>{isLinkMode ? "Создание связи..." : "Добавить связь"}</span>
|
||||||
|
</button> */}
|
||||||
|
|
||||||
|
{/* Зум + */}
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiZoomIn />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Зум - */}
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiZoomOut />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Fit */}
|
||||||
|
<button
|
||||||
|
onClick={handleFit}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiMove />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Экспорт */}
|
||||||
|
<button
|
||||||
|
onClick={onExport || exportData}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||||
|
style={btnStyle}
|
||||||
|
>
|
||||||
|
<FiDownload />
|
||||||
|
<span>Экспорт</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { GraphData } from "../types";
|
||||||
|
|
||||||
|
interface GraphStatsProps {
|
||||||
|
data: GraphData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphStats: React.FC<GraphStatsProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex gap-4 text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
||||||
|
</span>
|
||||||
|
<span>Агенты: {data.nodes.filter((n) => n.type === "agent").length}</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-sm"
|
||||||
|
style={{ backgroundColor: "var(--text-muted)" }}
|
||||||
|
></div>
|
||||||
|
<span>Связи: {data.links.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiLink } from "react-icons/fi";
|
||||||
|
import type { GraphNode } from "../types";
|
||||||
|
|
||||||
|
interface GraphStatusBarProps {
|
||||||
|
isLinkMode: boolean;
|
||||||
|
selectedNode: GraphNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphStatusBar: React.FC<GraphStatusBarProps> = ({
|
||||||
|
isLinkMode,
|
||||||
|
selectedNode,
|
||||||
|
}) => {
|
||||||
|
if (!isLinkMode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 left-4 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2"
|
||||||
|
style={{ backgroundColor: "#22c55e" }}
|
||||||
|
>
|
||||||
|
<FiLink /> Режим создания связей: кликните на два узла для соединения
|
||||||
|
{selectedNode && (
|
||||||
|
<span className="ml-2">Выбран: {selectedNode.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { ForceGraph } from "./ForceGraph";
|
||||||
|
export { GraphControls } from "./GraphControls";
|
||||||
|
export { GraphContextMenu } from "./GraphContextMenu";
|
||||||
|
export { GraphStatusBar } from "./GraphStatusBar";
|
||||||
|
export { GraphStats } from "./GraphStats";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { Graph } from "./Graph";
|
||||||
|
export { useGraphStore } from "./store/useGraphStore";
|
||||||
|
export type { GraphData, GraphNode, GraphLink } from "./types";
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { GraphData, GraphNode, GraphLink } from "../types";
|
||||||
|
|
||||||
|
interface GraphState {
|
||||||
|
data: GraphData;
|
||||||
|
highlightNodes: Set<string>;
|
||||||
|
highlightLinks: Set<GraphLink>;
|
||||||
|
isLinkMode: boolean;
|
||||||
|
selectedNode: GraphNode | null;
|
||||||
|
|
||||||
|
// Действия с данными
|
||||||
|
setData: (data: GraphData) => void;
|
||||||
|
addNode: (node: GraphNode) => void;
|
||||||
|
removeNode: (nodeId: string) => void;
|
||||||
|
addLink: (link: GraphLink) => void;
|
||||||
|
removeLink: (link: GraphLink) => void;
|
||||||
|
|
||||||
|
// Подсветка
|
||||||
|
setHighlight: (nodeIds: Set<string>, links: Set<GraphLink>) => void;
|
||||||
|
|
||||||
|
// Режим связи
|
||||||
|
toggleLinkMode: () => void;
|
||||||
|
setSelectedNode: (node: GraphNode | null) => void;
|
||||||
|
createLink: (sourceId: string, targetId: string) => void;
|
||||||
|
|
||||||
|
// Экспорт
|
||||||
|
exportData: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGraphStore = create<GraphState>((set, get) => ({
|
||||||
|
data: { nodes: [], links: [] },
|
||||||
|
highlightNodes: new Set(),
|
||||||
|
highlightLinks: new Set(),
|
||||||
|
isLinkMode: false,
|
||||||
|
selectedNode: null,
|
||||||
|
|
||||||
|
setData: (data) => set({ data }),
|
||||||
|
|
||||||
|
addNode: (node) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
nodes: [...state.data.nodes, node],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNode: (nodeId) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
nodes: state.data.nodes.filter((n) => n.id !== nodeId),
|
||||||
|
links: state.data.links.filter(
|
||||||
|
(l) => l.source !== nodeId && l.target !== nodeId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
addLink: (link) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
links: [...state.data.links, link],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeLink: (linkToRemove) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
links: state.data.links.filter((l) => l !== linkToRemove),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setHighlight: (nodeIds, links) =>
|
||||||
|
set({ highlightNodes: nodeIds, highlightLinks: links }),
|
||||||
|
|
||||||
|
toggleLinkMode: () =>
|
||||||
|
set((state) => ({
|
||||||
|
isLinkMode: !state.isLinkMode,
|
||||||
|
selectedNode: null,
|
||||||
|
})),
|
||||||
|
|
||||||
|
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||||
|
|
||||||
|
createLink: (sourceId, targetId) => {
|
||||||
|
const { data, addLink } = get();
|
||||||
|
|
||||||
|
const linkExists = data.links.some(
|
||||||
|
(link) =>
|
||||||
|
(link.source === sourceId && link.target === targetId) ||
|
||||||
|
(link.source === targetId && link.target === sourceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!linkExists) {
|
||||||
|
addLink({ source: sourceId, target: targetId, type: "custom" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportData: () => {
|
||||||
|
const { data } = get();
|
||||||
|
const dataStr = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([dataStr], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "graph-data.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "agent" | "service";
|
||||||
|
val?: number;
|
||||||
|
description?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
status?: "up" | "down";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphLink {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
node: GraphNode | null;
|
||||||
|
link: GraphLink | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API response types for GET /graph
|
||||||
|
export interface GraphDependencyTarget {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphDependency {
|
||||||
|
condition: string;
|
||||||
|
target: GraphDependencyTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphServiceNode {
|
||||||
|
dependencies: GraphDependency[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphAgentNode {
|
||||||
|
services: Record<string, GraphServiceNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphApiResponse {
|
||||||
|
nodes: Record<string, GraphAgentNode>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { MdAdd, MdArrowBack } from "react-icons/md";
|
||||||
|
import { GoTrash } from "react-icons/go";
|
||||||
|
import {
|
||||||
|
useIDEStore,
|
||||||
|
initialFiles as defaultInitialFiles,
|
||||||
|
} from "./store/useIDEStore";
|
||||||
|
import type { FileNode } from "./types";
|
||||||
|
import {
|
||||||
|
FileExplorer,
|
||||||
|
TabBar,
|
||||||
|
CodeEditor,
|
||||||
|
TitleBar,
|
||||||
|
StatusBar,
|
||||||
|
} from "./components";
|
||||||
|
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
||||||
|
|
||||||
|
interface IDEProps {
|
||||||
|
initialFiles?: FileNode;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkColors = {
|
||||||
|
bg: "#1e1e1e",
|
||||||
|
bgSecondary: "#252526",
|
||||||
|
bgTertiary: "#2d2d30",
|
||||||
|
border: "#3e3e42",
|
||||||
|
textPrimary: "#cccccc",
|
||||||
|
textSecondary: "#858585",
|
||||||
|
accent: "#0e639c",
|
||||||
|
accentHover: "#1177bb",
|
||||||
|
statusBar: "#007acc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const lightColors = {
|
||||||
|
bg: "#ffffff",
|
||||||
|
bgSecondary: "#f3f3f3",
|
||||||
|
bgTertiary: "#e8e8e8",
|
||||||
|
border: "#e0e0e0",
|
||||||
|
textPrimary: "#333333",
|
||||||
|
textSecondary: "#616161",
|
||||||
|
accent: "#0e639c",
|
||||||
|
accentHover: "#1177bb",
|
||||||
|
statusBar: "#007acc",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IDE: React.FC<IDEProps> = ({
|
||||||
|
initialFiles: externalFiles,
|
||||||
|
onBack,
|
||||||
|
}: IDEProps = {}) => {
|
||||||
|
const theme = useThemeStore((s) => s.theme);
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
const c = isDark ? darkColors : lightColors;
|
||||||
|
|
||||||
|
const files = useIDEStore((state) => state.files);
|
||||||
|
const openFiles = useIDEStore((state) => state.openFiles);
|
||||||
|
const activeFile = useIDEStore((state) => state.activeFile);
|
||||||
|
const createNewProject = useIDEStore((state) => state.createNewProject);
|
||||||
|
const selectFile = useIDEStore((state) => state.selectFile);
|
||||||
|
const updateFileContent = useIDEStore((state) => state.updateFileContent);
|
||||||
|
const saveActiveFile = useIDEStore((state) => state.saveActiveFile);
|
||||||
|
const closeFile = useIDEStore((state) => state.closeFile);
|
||||||
|
const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
|
||||||
|
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
|
||||||
|
const initialize = useIDEStore((state) => state.initialize);
|
||||||
|
const isInitialized = useIDEStore((state) => state.isInitialized);
|
||||||
|
const fetchTree = useIDEStore((state) => state.fetchTree);
|
||||||
|
const fetchInterpreters = useIDEStore((state) => state.fetchInterpreters);
|
||||||
|
|
||||||
|
// Загружаем интерпретаторы при инициализации
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInterpreters();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Обработка Ctrl+S
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
saveActiveFile();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [saveActiveFile]);
|
||||||
|
|
||||||
|
// При загрузке пробуем загрузить дерево с сервера
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) {
|
||||||
|
fetchTree().catch(() => {
|
||||||
|
// Только при ошибке — используем моковые данные
|
||||||
|
const state = useIDEStore.getState();
|
||||||
|
if (!state.files) {
|
||||||
|
const filesToInit = externalFiles || defaultInitialFiles;
|
||||||
|
initialize(filesToInit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
|
// Если проект не открыт
|
||||||
|
if (!files) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TitleBar />
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "40px",
|
||||||
|
left: "12px",
|
||||||
|
background: "transparent",
|
||||||
|
border: `1px solid ${c.border}`,
|
||||||
|
color: c.textPrimary,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "12px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.border;
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
e.currentTarget.style.borderColor = "#555";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = c.textPrimary;
|
||||||
|
e.currentTarget.style.borderColor = c.border;
|
||||||
|
}}
|
||||||
|
title="Go back"
|
||||||
|
>
|
||||||
|
<MdArrowBack size={16} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoTrash size={72} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "22px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
color: c.textPrimary,
|
||||||
|
fontWeight: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No project open
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "13px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
color: c.textSecondary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create a new project to get started
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={createNewProject}
|
||||||
|
style={{
|
||||||
|
padding: "10px 24px",
|
||||||
|
backgroundColor: c.accent,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.accentHover;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.accent;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAdd size={14} /> New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "30px",
|
||||||
|
backgroundColor: c.bgTertiary,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 8px",
|
||||||
|
borderBottom: `1px solid ${c.bg}`,
|
||||||
|
fontSize: "12px",
|
||||||
|
color: c.textPrimary,
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: c.textPrimary,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "11px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = c.border;
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = c.textPrimary;
|
||||||
|
}}
|
||||||
|
title="Go back"
|
||||||
|
>
|
||||||
|
<MdArrowBack size={14} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!onBack && <div />}
|
||||||
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{activeFile
|
||||||
|
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
|
||||||
|
: ""}
|
||||||
|
{files.name}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
{activeFile?.dirty && (
|
||||||
|
<button
|
||||||
|
onClick={saveActiveFile}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: c.textPrimary,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "11px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
title="Сохранить (Ctrl+S)"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||||
|
<div style={{ width: "260px", flexShrink: 0 }}>
|
||||||
|
<FileExplorer
|
||||||
|
files={files}
|
||||||
|
onDeleteRoot={useIDEStore.getState().deleteRoot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabBar
|
||||||
|
openFiles={openFiles}
|
||||||
|
activeFile={activeFile}
|
||||||
|
onSelectFile={selectFile}
|
||||||
|
onCloseFile={closeFile}
|
||||||
|
onCloseAll={closeAllFiles}
|
||||||
|
onCloseOthers={closeOtherFiles}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
filePath={activeFile?.path || ""}
|
||||||
|
content={activeFile?.content || ""}
|
||||||
|
onChange={updateFileContent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar activeFile={activeFile} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IDE;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { apiClient } from "@/shared/api/axios.instance";
|
||||||
|
import type { Interpreter } from "../types";
|
||||||
|
|
||||||
|
export interface ScriptNodeDto {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
content?: string;
|
||||||
|
children?: string[];
|
||||||
|
interpreter_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptResponse {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
interpreter_id: number;
|
||||||
|
path: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScriptPayload {
|
||||||
|
content: string;
|
||||||
|
interpreter_id: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScriptPayload {
|
||||||
|
content: string;
|
||||||
|
interpreter_id: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScriptPayload {
|
||||||
|
stdin?: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunScriptResponse {
|
||||||
|
command: string[];
|
||||||
|
id: number;
|
||||||
|
wait_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInterpreterPayload {
|
||||||
|
argv: string[];
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobWaitResponse {
|
||||||
|
command: string[];
|
||||||
|
id: number;
|
||||||
|
status: number;
|
||||||
|
stderr: string;
|
||||||
|
stdin: string;
|
||||||
|
stdout: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiClient уже имеет интерсептор для Authorization header
|
||||||
|
export const scriptsApi = {
|
||||||
|
getInterpreters: async (): Promise<Interpreter[]> => {
|
||||||
|
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTree: async (): Promise<ScriptNodeDto[]> => {
|
||||||
|
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createScript: async (
|
||||||
|
payload: CreateScriptPayload,
|
||||||
|
): Promise<ScriptResponse> => {
|
||||||
|
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateScript: async (
|
||||||
|
id: number,
|
||||||
|
payload: UpdateScriptPayload,
|
||||||
|
): Promise<ScriptResponse> => {
|
||||||
|
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteScript: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/scripts/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createFolder: async (path: string): Promise<{ path: string }> => {
|
||||||
|
const res = await apiClient.post<{ path: string }>("/scripts/folder", {
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFolder: async (path: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/scripts/folder`, { data: { path } });
|
||||||
|
},
|
||||||
|
|
||||||
|
rename: async (payload: {
|
||||||
|
old_path: string;
|
||||||
|
new_path: string;
|
||||||
|
}): Promise<{ path: string }> => {
|
||||||
|
const res = await apiClient.post<{ path: string }>(
|
||||||
|
"/scripts/rename",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
runScript: async (
|
||||||
|
id: number,
|
||||||
|
payload: RunScriptPayload,
|
||||||
|
): Promise<RunScriptResponse> => {
|
||||||
|
const res = await apiClient.post<RunScriptResponse>(
|
||||||
|
`/scripts/${id}/run`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
waitJob: async (id: number): Promise<JobWaitResponse> => {
|
||||||
|
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createInterpreter: async (
|
||||||
|
payload: CreateInterpreterPayload,
|
||||||
|
): Promise<Interpreter> => {
|
||||||
|
const res = await apiClient.post<Interpreter>(
|
||||||
|
"/scripts/interpreters",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { MdClose, MdAdd } from "react-icons/md";
|
||||||
|
import { scriptsApi } from "../api/scripts.api";
|
||||||
|
import type { CreateInterpreterPayload } from "../api/scripts.api";
|
||||||
|
|
||||||
|
interface AddInterpreterModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [argv, setArgv] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
nameRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !label.trim()) {
|
||||||
|
setError("Name and Label are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: CreateInterpreterPayload = {
|
||||||
|
name: name.trim(),
|
||||||
|
label: label.trim(),
|
||||||
|
argv: argv
|
||||||
|
.split(" ")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
await scriptsApi.createInterpreter(payload);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Failed to create interpreter:", e);
|
||||||
|
setError(e?.response?.data?.detail || "Failed to create interpreter");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
width: "420px",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Interpreter
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name <span style={{ color: "#f44747" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={nameRef}
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Python, Node.js, etc."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Label <span style={{ color: "#f44747" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="python3, node, etc."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Args */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Arguments <span style={{ color: "#858585" }}>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={argv}
|
||||||
|
onChange={(e) => setArgv(e.target.value)}
|
||||||
|
placeholder="-u -O (space separated)"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
||||||
|
border: "1px solid #f44747",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#f44747",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: loading ? "#555" : "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏳
|
||||||
|
</span>
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MdAdd size={16} />
|
||||||
|
Add Interpreter
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Editor from "@monaco-editor/react";
|
||||||
|
import { FiFolder } from "react-icons/fi";
|
||||||
|
import { getLanguage } from "../helpers/fileTree";
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||||
|
filePath,
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{filePath ? (
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={getLanguage(filePath)}
|
||||||
|
value={content}
|
||||||
|
onChange={(value) => onChange(value || "")}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
|
||||||
|
tabSize: 4,
|
||||||
|
wordWrap: "on",
|
||||||
|
lineNumbers: "on",
|
||||||
|
automaticLayout: true,
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
smoothScrolling: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: "#858585",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "24px",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiFolder size={64} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
color: "#cccccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome to Web VS Code
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
|
||||||
|
Right-click on a folder to create files
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#0e639c" }}>
|
||||||
|
Or right-click anywhere in the explorer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
|
||||||
|
|
||||||
|
const MenuItem: React.FC<{
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ onClick, danger, children }) => (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: danger ? "#f48771" : "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onNewFile: () => void;
|
||||||
|
onNewFolder: () => void;
|
||||||
|
onRename: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
hasNode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onNewFile,
|
||||||
|
onNewFolder,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
hasNode,
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = () => onClose();
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "180px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={onNewFile}>
|
||||||
|
<FiFile /> New File
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onNewFolder}>
|
||||||
|
<FiFolder /> New Folder
|
||||||
|
</MenuItem>
|
||||||
|
{hasNode && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "1px",
|
||||||
|
backgroundColor: "#3e3e42",
|
||||||
|
margin: "4px 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem onClick={onRename}>
|
||||||
|
<FiEdit3 /> Rename
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={onDelete} danger>
|
||||||
|
<FiTrash2 /> Delete
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
|
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
|
||||||
|
import { GoKebabHorizontal } from "react-icons/go";
|
||||||
|
import { MdClose, MdAdd } from "react-icons/md";
|
||||||
|
import { FileTreeItem } from "./FileTreeItem";
|
||||||
|
import { ContextMenu } from "./ContextMenu";
|
||||||
|
import { InputDialog } from "./InputDialog";
|
||||||
|
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
|
||||||
|
import { useIDEStore } from "../store/useIDEStore";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface FileExplorerProps {
|
||||||
|
files: FileNode;
|
||||||
|
onDeleteRoot: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileExplorer: React.FC<FileExplorerProps> = ({
|
||||||
|
files,
|
||||||
|
onDeleteRoot,
|
||||||
|
}) => {
|
||||||
|
const store = useIDEStore();
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Фокус на инпут при открытии поиска
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSearch) {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [showSearch]);
|
||||||
|
|
||||||
|
const handleSearchBlur = useCallback(() => {
|
||||||
|
// Скрываем поиск при потере фокуса с небольшой задержкой,
|
||||||
|
// чтобы клики по кнопке очистки успели сработать
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
searchInputRef.current &&
|
||||||
|
!searchInputRef.current.contains(document.activeElement)
|
||||||
|
) {
|
||||||
|
setShowSearch(false);
|
||||||
|
store.setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const handleEmptyContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Загружаем интерпретаторы перед открытием меню
|
||||||
|
if (store.interpreters.length === 0) {
|
||||||
|
store.fetchInterpreters();
|
||||||
|
}
|
||||||
|
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загружаем интерпретаторы при монтировании компонента
|
||||||
|
useEffect(() => {
|
||||||
|
if (store.interpreters.length === 0) {
|
||||||
|
store.fetchInterpreters();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredFiles = store.searchQuery
|
||||||
|
? (files.children || [])
|
||||||
|
.map((child) => filterTree(child, store.searchQuery))
|
||||||
|
.filter((child): child is FileNode => child !== null)
|
||||||
|
: files.children || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (store.searchQuery && files) {
|
||||||
|
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
|
||||||
|
if (pathsToExpand.size > 0) {
|
||||||
|
store.autoExpandPaths(pathsToExpand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [store.searchQuery, files, store.autoExpandPaths]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
}}
|
||||||
|
onContextMenu={handleEmptyContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0 8px",
|
||||||
|
height: "35px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#bbbbbb",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
EXPLORER
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!showSearch) {
|
||||||
|
setShowSearch(true);
|
||||||
|
} else {
|
||||||
|
setShowSearch(false);
|
||||||
|
store.setSearchQuery("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: showSearch ? "#cccccc" : "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Search in files"
|
||||||
|
>
|
||||||
|
<FiSearch size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={store.collapseAllFolders}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
}}
|
||||||
|
title="Collapse All"
|
||||||
|
>
|
||||||
|
<FiMinus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={store.expandAllFolders}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
e.currentTarget.style.color = "#cccccc";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
}}
|
||||||
|
title="Expand All"
|
||||||
|
>
|
||||||
|
<GoKebabHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: store.searchQuery
|
||||||
|
? "1px solid #007acc"
|
||||||
|
: "1px solid transparent",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0 6px",
|
||||||
|
transition: "border-color 0.1s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSearch size={13} color="#858585" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={store.searchQuery}
|
||||||
|
onChange={(e) => store.setSearchQuery(e.target.value)}
|
||||||
|
onBlur={handleSearchBlur}
|
||||||
|
placeholder="Search..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "5px 6px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "12px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{store.searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => store.setSearchQuery("")}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||||
|
{filteredFiles.length > 0 ? (
|
||||||
|
filteredFiles.map((child, idx) => (
|
||||||
|
<FileTreeItem
|
||||||
|
key={idx}
|
||||||
|
node={child}
|
||||||
|
level={0}
|
||||||
|
onFileSelect={store.selectFile}
|
||||||
|
selectedFile={store.activeFile?.path || null}
|
||||||
|
onContextMenu={handleNodeContextMenu}
|
||||||
|
expandedFolders={store.expandedFolders}
|
||||||
|
onToggleFolder={store.toggleFolder}
|
||||||
|
onDelete={store.handleDeleteNode}
|
||||||
|
searchQuery={store.searchQuery}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
color: "#858585",
|
||||||
|
fontSize: "13px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{store.contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={store.contextMenu.x}
|
||||||
|
y={store.contextMenu.y}
|
||||||
|
onClose={() => store.setContextMenu(null)}
|
||||||
|
onNewFile={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "newFile",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onNewFolder={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "newFolder",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onRename={() => {
|
||||||
|
store.setDialog({
|
||||||
|
type: "rename",
|
||||||
|
node: store.contextMenu?.node || null,
|
||||||
|
});
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (store.contextMenu?.node) {
|
||||||
|
store.handleDeleteNode(store.contextMenu.node);
|
||||||
|
}
|
||||||
|
store.setContextMenu(null);
|
||||||
|
}}
|
||||||
|
hasNode={!!store.contextMenu.node}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{store.dialog && (
|
||||||
|
<InputDialog
|
||||||
|
title={
|
||||||
|
store.dialog.type === "newFile"
|
||||||
|
? "New File"
|
||||||
|
: store.dialog.type === "newFolder"
|
||||||
|
? "New Folder"
|
||||||
|
: "Rename"
|
||||||
|
}
|
||||||
|
initialValue={
|
||||||
|
store.dialog.type === "rename" && store.dialog.node
|
||||||
|
? store.dialog.node.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onConfirm={(value, interpreterId) => {
|
||||||
|
store.handleDialogConfirm(value, interpreterId);
|
||||||
|
}}
|
||||||
|
onCancel={() => store.setDialog(null)}
|
||||||
|
interpreters={
|
||||||
|
store.dialog.type === "newFile" ? store.interpreters : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
import { FilePickerItem } from "./FilePickerItem";
|
||||||
|
import { useFilePickerStore } from "../store/useFilePickerStore";
|
||||||
|
import { TerminalOutput } from "@/modules/terminal";
|
||||||
|
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
||||||
|
|
||||||
|
interface FilePickerProps {
|
||||||
|
files: FileNode;
|
||||||
|
onRun?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePickerTree: React.FC<{
|
||||||
|
node: FileNode;
|
||||||
|
level: number;
|
||||||
|
onRun?: (path: string) => void;
|
||||||
|
}> = ({ node, level, onRun }) => {
|
||||||
|
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
||||||
|
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
||||||
|
|
||||||
|
const nodePath = node.path || node.name;
|
||||||
|
const isExpanded = expandedFolders.has(nodePath);
|
||||||
|
|
||||||
|
if (node.type === "file") {
|
||||||
|
return (
|
||||||
|
<FilePickerItem
|
||||||
|
name={node.name}
|
||||||
|
type="file"
|
||||||
|
path={nodePath}
|
||||||
|
level={level}
|
||||||
|
onRun={onRun}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilePickerItem
|
||||||
|
name={node.name}
|
||||||
|
type="folder"
|
||||||
|
path={nodePath}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
level={level}
|
||||||
|
onToggleFolder={toggleFolder}
|
||||||
|
>
|
||||||
|
{node.children?.map((child, idx) => (
|
||||||
|
<FilePickerTree
|
||||||
|
key={idx}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
onRun={onRun}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FilePickerItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
|
||||||
|
const terminalOpen = useTerminalStore((s) => s.isOpen);
|
||||||
|
const jobs = useTerminalStore((s) => s.jobs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
overflowY: "auto",
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Terminal — сверху, над списком файлов */}
|
||||||
|
{terminalOpen && jobs.length > 0 && (
|
||||||
|
<div style={{ height: 250 }}>
|
||||||
|
<TerminalOutput />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(files.children || []).map((child, idx) => (
|
||||||
|
<FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
FiChevronRight,
|
||||||
|
FiChevronDown,
|
||||||
|
FiFile,
|
||||||
|
FiFolder,
|
||||||
|
FiPlay,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
|
interface FilePickerItemProps {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
path: string;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
level: number;
|
||||||
|
onToggleSelect?: (path: string) => void;
|
||||||
|
onToggleFolder?: (path: string) => void;
|
||||||
|
onRun?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
path,
|
||||||
|
isExpanded,
|
||||||
|
children,
|
||||||
|
level,
|
||||||
|
onToggleSelect,
|
||||||
|
onToggleFolder,
|
||||||
|
onRun,
|
||||||
|
}) => {
|
||||||
|
const isFolder = type === "folder";
|
||||||
|
const extension = name.includes(".")
|
||||||
|
? name.split(".").pop()?.toUpperCase()
|
||||||
|
: "";
|
||||||
|
const paddingLeft = 12 + level * 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingLeft: `${paddingLeft}px`,
|
||||||
|
paddingRight: "12px",
|
||||||
|
height: "36px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (isFolder && onToggleFolder) {
|
||||||
|
onToggleFolder(path);
|
||||||
|
} else if (!isFolder && onToggleSelect) {
|
||||||
|
onToggleSelect(path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Folder expand icon */}
|
||||||
|
{isFolder && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
display: "flex",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FiChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<FiChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File/Folder icon */}
|
||||||
|
<span style={{ display: "flex", flexShrink: 0 }}>
|
||||||
|
{isFolder ? (
|
||||||
|
<FiFolder size={15} color="var(--accent)" />
|
||||||
|
) : (
|
||||||
|
<FiFile size={15} color="var(--text-secondary)" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Extension badge — только у файлов */}
|
||||||
|
{!isFolder && extension && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "2px 6px",
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderRadius: "3px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extension}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run button — только у файлов */}
|
||||||
|
{!isFolder && onRun && (
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "4px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
borderRadius: "3px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRun(path);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#238636";
|
||||||
|
e.currentTarget.style.color = "#ffffff";
|
||||||
|
e.currentTarget.style.borderColor = "#2ea043";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "var(--text-secondary)";
|
||||||
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Run script"
|
||||||
|
>
|
||||||
|
<FiPlay size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children */}
|
||||||
|
{isFolder && isExpanded && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
|
||||||
|
import { GoFile } from "react-icons/go";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface FileTreeItemProps {
|
||||||
|
node: FileNode;
|
||||||
|
level: number;
|
||||||
|
onFileSelect: (node: FileNode) => void;
|
||||||
|
selectedFile: string | null;
|
||||||
|
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
onToggleFolder: (path: string) => void;
|
||||||
|
onDelete: (node: FileNode) => void;
|
||||||
|
isRoot?: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
|
||||||
|
node,
|
||||||
|
level,
|
||||||
|
onFileSelect,
|
||||||
|
selectedFile,
|
||||||
|
onContextMenu,
|
||||||
|
expandedFolders,
|
||||||
|
onToggleFolder,
|
||||||
|
onDelete,
|
||||||
|
isRoot,
|
||||||
|
searchQuery,
|
||||||
|
}) => {
|
||||||
|
const isFolder = node.type === "folder";
|
||||||
|
const isSelected = selectedFile === node.path && !isFolder;
|
||||||
|
const isExpanded = expandedFolders.has(node.path || node.name);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isFolder) {
|
||||||
|
onToggleFolder(node.path || node.name);
|
||||||
|
} else {
|
||||||
|
onFileSelect(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightText = (text: string, query: string) => {
|
||||||
|
if (!query) return text;
|
||||||
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (idx === -1) return text;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, idx)}
|
||||||
|
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
|
||||||
|
{text.slice(idx, idx + query.length)}
|
||||||
|
</span>
|
||||||
|
{text.slice(idx + query.length)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
onContextMenu={(e) => onContextMenu(e, node)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
|
||||||
|
paddingTop: "4px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
backgroundColor: isSelected ? "#094771" : "transparent",
|
||||||
|
color: isSelected ? "#fff" : "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
transition: "background-color 0.1s",
|
||||||
|
userSelect: "none",
|
||||||
|
minHeight: "28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
width: "16px",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFolder ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<FiChevronDown />
|
||||||
|
) : (
|
||||||
|
<FiChevronRight />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<GoFile />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
|
||||||
|
</span>
|
||||||
|
{hovered && !isRoot && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
title={`Delete ${node.name}`}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "3px",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "20px",
|
||||||
|
height: "20px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#f48771";
|
||||||
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTrash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isFolder && isExpanded && node.children && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child, idx) => (
|
||||||
|
<FileTreeItem
|
||||||
|
key={idx}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
onToggleFolder={onToggleFolder}
|
||||||
|
onDelete={onDelete}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import type { Interpreter } from "../types";
|
||||||
|
|
||||||
|
interface InputDialogProps {
|
||||||
|
title: string;
|
||||||
|
initialValue?: string;
|
||||||
|
onConfirm: (value: string, interpreterId?: number) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
interpreters?: Interpreter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputDialog: React.FC<InputDialogProps> = ({
|
||||||
|
title,
|
||||||
|
initialValue = "",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
interpreters,
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [interpreterId, setInterpreterId] = useState<number | undefined>(
|
||||||
|
interpreters?.[0]?.id,
|
||||||
|
);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showInterpreterDropdown = interpreters && interpreters.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#2d2d30",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
minWidth: "320px",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
||||||
|
Enter a name
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
value.trim() &&
|
||||||
|
onConfirm(value.trim(), interpreterId)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "14px",
|
||||||
|
marginBottom: showInterpreterDropdown ? "12px" : "20px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Interpreter dropdown */}
|
||||||
|
{showInterpreterDropdown && (
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#858585",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Interpreter
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={interpreterId}
|
||||||
|
onChange={(e) => setInterpreterId(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "14px",
|
||||||
|
outline: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{interpreters.map((interp) => (
|
||||||
|
<option key={interp.id} value={interp.id}>
|
||||||
|
{interp.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "1px solid #0e639c",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#0e639c",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
value.trim() && onConfirm(value.trim(), interpreterId)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor: "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { MdClose } from "react-icons/md";
|
||||||
|
import { scriptsApi } from "../api/scripts.api";
|
||||||
|
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
||||||
|
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
||||||
|
|
||||||
|
interface RunScriptModalProps {
|
||||||
|
scriptPath: string;
|
||||||
|
scriptId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RunScriptModal: React.FC<RunScriptModalProps> = ({
|
||||||
|
scriptPath,
|
||||||
|
scriptId,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [selectedAgentIdx, setSelectedAgentIdx] = useState(0);
|
||||||
|
const [stdinValue, setStdinValue] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
const agents = useAgentStore((s) => s.agents);
|
||||||
|
const addJob = useTerminalStore((s) => s.addJob);
|
||||||
|
const openTerminal = useTerminalStore((s) => s.openTerminal);
|
||||||
|
|
||||||
|
const selectedAgent = agents[selectedAgentIdx];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!selectedAgent) {
|
||||||
|
setError("No agents available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Запускаем скрипт
|
||||||
|
const runResult = await scriptsApi.runScript(scriptId, {
|
||||||
|
stdin: stdinValue,
|
||||||
|
token: selectedAgent.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Добавляем джоб в терминал
|
||||||
|
addJob({
|
||||||
|
id: runResult.id,
|
||||||
|
scriptPath,
|
||||||
|
command: runResult.command,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Открываем терминал
|
||||||
|
openTerminal();
|
||||||
|
|
||||||
|
// 4. Ждём завершения по id
|
||||||
|
const jobResult = await scriptsApi.waitJob(runResult.id);
|
||||||
|
|
||||||
|
// 5. Обновляем существующий джоб (не создаём новый!)
|
||||||
|
const terminalStore = useTerminalStore.getState();
|
||||||
|
terminalStore.updateJob(runResult.id, {
|
||||||
|
command: jobResult.command,
|
||||||
|
stdin: jobResult.stdin,
|
||||||
|
status: jobResult.status,
|
||||||
|
stdout: jobResult.stdout,
|
||||||
|
stderr: jobResult.stderr,
|
||||||
|
isRunning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Failed to run script:", e);
|
||||||
|
setError(e?.response?.data?.detail || "Failed to run script");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--card-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
width: "420px",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Run Script
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
{/* Script path */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Script
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scriptPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent selector */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Agent <span style={{ color: "#f44747" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
ref={inputRef}
|
||||||
|
value={selectedAgentIdx}
|
||||||
|
onChange={(e) => setSelectedAgentIdx(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agents.length === 0 && (
|
||||||
|
<option value="">No agents available</option>
|
||||||
|
)}
|
||||||
|
{agents.map((agent, idx) => (
|
||||||
|
<option key={agent.label} value={idx}>
|
||||||
|
{agent.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stdin (optional) */}
|
||||||
|
<div style={{ marginBottom: "16px" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stdin <span style={{ color: "#858585" }}>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={stdinValue}
|
||||||
|
onChange={(e) => setStdinValue(e.target.value)}
|
||||||
|
placeholder="Enter input data..."
|
||||||
|
rows={4}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "var(--input-bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
resize: "vertical",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
||||||
|
border: "1px solid #f44747",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#f44747",
|
||||||
|
fontSize: "12px",
|
||||||
|
marginBottom: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Run button */}
|
||||||
|
<button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={loading || !selectedAgent}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: loading || !selectedAgent ? "#555" : "#0e639c",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: loading || !selectedAgent ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⏳
|
||||||
|
</span>
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>▶ Run</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiGitBranch, FiCheckCircle, FiAlertCircle } from "react-icons/fi";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBar: React.FC<StatusBarProps> = ({ activeFile }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "22px",
|
||||||
|
backgroundColor: "#007acc",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#ffffff",
|
||||||
|
userSelect: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
|
<FiGitBranch size={12} /> main
|
||||||
|
</span>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||||
|
{activeFile && (
|
||||||
|
<span>
|
||||||
|
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
||||||
|
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Web VS Code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { GoFile } from "react-icons/go";
|
||||||
|
import { MdClose } from "react-icons/md";
|
||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
openFiles: FileNode[];
|
||||||
|
activeFile: FileNode | null;
|
||||||
|
onSelectFile: (file: FileNode) => void;
|
||||||
|
onCloseFile: (file: FileNode) => void;
|
||||||
|
onCloseAll: () => void;
|
||||||
|
onCloseOthers: (file: FileNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabBar: React.FC<TabBarProps> = ({
|
||||||
|
openFiles,
|
||||||
|
activeFile,
|
||||||
|
onSelectFile,
|
||||||
|
onCloseFile,
|
||||||
|
onCloseAll,
|
||||||
|
onCloseOthers,
|
||||||
|
}) => {
|
||||||
|
const [showContextMenu, setShowContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, file: FileNode) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowContextMenu({ x: e.clientX, y: e.clientY, file });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
overflowX: "auto",
|
||||||
|
minHeight: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
padding: "0 12px",
|
||||||
|
gap: "8px",
|
||||||
|
borderRight: "1px solid #3e3e42",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCloseAll}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#cccccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
title="Close All"
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
<span style={{ fontSize: "11px" }}>Close All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{openFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => onSelectFile(file)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor:
|
||||||
|
activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30",
|
||||||
|
color: activeFile?.path === file.path ? "#fff" : "#cccccc",
|
||||||
|
borderRight: "1px solid #3e3e42",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "13px",
|
||||||
|
gap: "10px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "all 0.1s",
|
||||||
|
borderTop:
|
||||||
|
activeFile?.path === file.path
|
||||||
|
? "2px solid #0e639c"
|
||||||
|
: "2px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GoFile />
|
||||||
|
<span>{file.name}</span>
|
||||||
|
{file.dirty && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "8px",
|
||||||
|
height: "8px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#fbbf24",
|
||||||
|
marginLeft: "-4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCloseFile(file);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "16px",
|
||||||
|
padding: "0 4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "#fff";
|
||||||
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "#858585";
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showContextMenu && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: showContextMenu.y,
|
||||||
|
left: showContextMenu.x,
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
border: "1px solid #3e3e42",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "160px",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCloseOthers(showContextMenu.file);
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close Others
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
onCloseAll();
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#cccccc",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close All
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FiGitBranch, FiCheckCircle } from "react-icons/fi";
|
||||||
|
|
||||||
|
export const TitleBar: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "32px",
|
||||||
|
backgroundColor: "#2d2d30",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#ed6a5e",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#f5bd4f",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#61c454",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
|
||||||
|
Web VS Code
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiGitBranch size={12} color="#858585" />
|
||||||
|
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
|
||||||
|
<FiCheckCircle size={12} color="#61c454" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { ContextMenu } from "./ContextMenu";
|
||||||
|
export { InputDialog } from "./InputDialog";
|
||||||
|
export { FileTreeItem } from "./FileTreeItem";
|
||||||
|
export { FileExplorer } from "./FileExplorer";
|
||||||
|
export { TabBar } from "./TabBar";
|
||||||
|
export { CodeEditor } from "./CodeEditor";
|
||||||
|
export { TitleBar } from "./TitleBar";
|
||||||
|
export { StatusBar } from "./StatusBar";
|
||||||
|
export { FilePickerItem } from "./FilePickerItem";
|
||||||
|
export { FilePicker } from "./FilePicker";
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { FileNode } from "../types";
|
||||||
|
|
||||||
|
export const addPaths = (node: FileNode, parentPath: string = ""): FileNode => {
|
||||||
|
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
||||||
|
const newNode = { ...node, path: currentPath };
|
||||||
|
if (newNode.children) {
|
||||||
|
newNode.children = newNode.children.map((child) =>
|
||||||
|
addPaths(child, currentPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllFolderPaths = (node: FileNode): string[] => {
|
||||||
|
let paths: string[] = [];
|
||||||
|
if (node.type === "folder") {
|
||||||
|
paths.push(node.path || node.name);
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
paths = [...paths, ...getAllFolderPaths(child)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findNode = (node: FileNode, path: string): FileNode | null => {
|
||||||
|
if (node.path === path) return node;
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findNode(child, path);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNode = (node: FileNode, path: string): FileNode | null => {
|
||||||
|
if (node.path === path) return null;
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const filtered = node.children.filter((child) => child.path !== path);
|
||||||
|
const mapped = filtered
|
||||||
|
.map((child) => deleteNode(child, path))
|
||||||
|
.filter((child): child is FileNode => child !== null);
|
||||||
|
return { ...node, children: mapped };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addNode = (
|
||||||
|
node: FileNode,
|
||||||
|
parentPath: string,
|
||||||
|
newNode: FileNode,
|
||||||
|
): FileNode => {
|
||||||
|
if (node.path === parentPath) {
|
||||||
|
const newPath = addPaths(newNode, node.path);
|
||||||
|
return { ...node, children: [...(node.children || []), newPath] };
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map((child) =>
|
||||||
|
addNode(child, parentPath, newNode),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameNode = (
|
||||||
|
node: FileNode,
|
||||||
|
oldPath: string,
|
||||||
|
newName: string,
|
||||||
|
): FileNode | null => {
|
||||||
|
if (node.path === oldPath) {
|
||||||
|
const pathParts = node.path?.split("/") || [];
|
||||||
|
pathParts[pathParts.length - 1] = newName;
|
||||||
|
const newPath = pathParts.join("/");
|
||||||
|
const renamedNode = { ...node, name: newName, path: newPath };
|
||||||
|
|
||||||
|
if (renamedNode.children) {
|
||||||
|
renamedNode.children = renamedNode.children.map((child) => {
|
||||||
|
const oldChildPath = child.path || "";
|
||||||
|
const newChildPath = oldChildPath.replace(oldPath, newPath);
|
||||||
|
return (
|
||||||
|
renameNode(
|
||||||
|
child,
|
||||||
|
oldChildPath,
|
||||||
|
newChildPath.split("/").pop() || "",
|
||||||
|
) || child
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return renamedNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map(
|
||||||
|
(child) => renameNode(child, oldPath, newName) || child,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterTree = (node: FileNode, query: string): FileNode | null => {
|
||||||
|
if (!query) return node;
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
if (node.type === "file") {
|
||||||
|
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const filteredChildren = node.children
|
||||||
|
.map((child) => filterTree(child, query))
|
||||||
|
.filter((child): child is FileNode => child !== null);
|
||||||
|
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectPathsToExpand = (
|
||||||
|
node: FileNode,
|
||||||
|
query: string,
|
||||||
|
): Set<string> => {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
if (!query) return paths;
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
const search = (n: FileNode, currentPath: string) => {
|
||||||
|
if (n.name.toLowerCase().includes(lowerQuery)) {
|
||||||
|
const pathParts = currentPath.split("/");
|
||||||
|
for (let i = 1; i < pathParts.length; i++) {
|
||||||
|
paths.add(pathParts.slice(0, i).join("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.children) {
|
||||||
|
n.children.forEach((child) => {
|
||||||
|
const childPath = child.path || `${currentPath}/${child.name}`;
|
||||||
|
search(child, childPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
search(node, node.path || node.name);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLanguage = (path: string) => {
|
||||||
|
const ext = path.split(".").pop();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
py: "python",
|
||||||
|
js: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
jsx: "javascript",
|
||||||
|
tsx: "typescript",
|
||||||
|
json: "json",
|
||||||
|
md: "markdown",
|
||||||
|
css: "css",
|
||||||
|
html: "html",
|
||||||
|
};
|
||||||
|
return map[ext || ""] || "plaintext";
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { IDE } from "./IDE";
|
||||||
|
export { FilePicker } from "./components/FilePicker";
|
||||||
|
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
||||||
|
export { useFilePickerStore } from "./store/useFilePickerStore";
|
||||||
|
export type { FileNode } from "./types";
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface FilePickerState {
|
||||||
|
selectedPaths: Set<string>;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
|
||||||
|
toggleSelection: (path: string) => void;
|
||||||
|
selectAll: (paths: string[]) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
toggleFolder: (path: string) => void;
|
||||||
|
getSelectedPaths: () => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
||||||
|
selectedPaths: new Set(),
|
||||||
|
expandedFolders: new Set(),
|
||||||
|
|
||||||
|
toggleSelection: (path: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.selectedPaths);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return { selectedPaths: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll: (paths: string[]) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.selectedPaths);
|
||||||
|
paths.forEach((p) => newSet.add(p));
|
||||||
|
return { selectedPaths: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: () => {
|
||||||
|
set({ selectedPaths: new Set() });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFolder: (path: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.expandedFolders);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return { expandedFolders: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedPaths: () => {
|
||||||
|
return Array.from(get().selectedPaths);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { FileNode, Interpreter, DialogState } from "../types";
|
||||||
|
import {
|
||||||
|
addPaths,
|
||||||
|
getAllFolderPaths,
|
||||||
|
findNode,
|
||||||
|
deleteNode,
|
||||||
|
addNode,
|
||||||
|
renameNode,
|
||||||
|
} from "../helpers/fileTree";
|
||||||
|
import { scriptsApi } from "../api/scripts.api";
|
||||||
|
|
||||||
|
export const initialFiles: FileNode = {
|
||||||
|
name: "my-project",
|
||||||
|
type: "folder",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "src",
|
||||||
|
type: "folder",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "main.py",
|
||||||
|
type: "file",
|
||||||
|
content:
|
||||||
|
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "utils.py",
|
||||||
|
type: "file",
|
||||||
|
content: "def helper():\n return 42",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
type: "file",
|
||||||
|
content: "# My Project\n\nWelcome!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IDEFileNode extends FileNode {
|
||||||
|
dirty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IDEState {
|
||||||
|
// Файловая система
|
||||||
|
files: FileNode | null;
|
||||||
|
openFiles: IDEFileNode[];
|
||||||
|
activeFile: IDEFileNode | null;
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
searchQuery: string;
|
||||||
|
showSearch: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
interpreters: Interpreter[];
|
||||||
|
|
||||||
|
// Диалоги и контекстные меню
|
||||||
|
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
||||||
|
dialog: DialogState | null;
|
||||||
|
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
||||||
|
|
||||||
|
// Действия с файлами
|
||||||
|
selectFile: (node: FileNode) => void;
|
||||||
|
updateFileContent: (content: string) => void;
|
||||||
|
saveActiveFile: () => Promise<void>;
|
||||||
|
closeFile: (file: FileNode) => void;
|
||||||
|
closeAllFiles: () => void;
|
||||||
|
closeOtherFiles: (file: FileNode) => void;
|
||||||
|
|
||||||
|
// Действия с деревом
|
||||||
|
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => void;
|
||||||
|
toggleFolder: (path: string) => void;
|
||||||
|
expandAllFolders: () => void;
|
||||||
|
collapseAllFolders: () => void;
|
||||||
|
autoExpandPaths: (paths: Set<string>) => void;
|
||||||
|
deleteRoot: () => void;
|
||||||
|
createNewProject: () => void;
|
||||||
|
|
||||||
|
// Интерпретаторы
|
||||||
|
fetchInterpreters: () => Promise<void>;
|
||||||
|
|
||||||
|
// API методы
|
||||||
|
fetchTree: () => Promise<void>;
|
||||||
|
createScript: (payload: {
|
||||||
|
content: string;
|
||||||
|
interpreter_id: number;
|
||||||
|
path: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
createFolder: (path: string) => Promise<void>;
|
||||||
|
updateScript: (
|
||||||
|
id: number,
|
||||||
|
payload: { content: string; interpreter_id: number; path: string },
|
||||||
|
) => Promise<void>;
|
||||||
|
deleteScript: (id: number) => Promise<void>;
|
||||||
|
deleteFolder: (payload: { path: string }) => Promise<void>;
|
||||||
|
saveActiveFile: () => Promise<void>;
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
toggleSearch: () => void;
|
||||||
|
|
||||||
|
// Контекстные меню и диалоги
|
||||||
|
setContextMenu: (
|
||||||
|
menu: { x: number; y: number; node: FileNode | null } | null,
|
||||||
|
) => void;
|
||||||
|
setDialog: (
|
||||||
|
dialog: {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
} | null,
|
||||||
|
) => void;
|
||||||
|
setTabContextMenu: (
|
||||||
|
menu: { x: number; y: number; file: FileNode } | null,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
initialize: (initialFiles: FileNode) => void;
|
||||||
|
|
||||||
|
// Диалог подтверждения
|
||||||
|
handleDialogConfirm: (value: string, interpreterId?: number) => Promise<void>;
|
||||||
|
handleDeleteNode: (node: FileNode) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIDEStore = create<IDEState>((set, get) => ({
|
||||||
|
// Начальное состояние
|
||||||
|
files: null,
|
||||||
|
openFiles: [],
|
||||||
|
activeFile: null,
|
||||||
|
expandedFolders: new Set(),
|
||||||
|
searchQuery: "",
|
||||||
|
showSearch: false,
|
||||||
|
isInitialized: false,
|
||||||
|
|
||||||
|
contextMenu: null,
|
||||||
|
dialog: null,
|
||||||
|
tabContextMenu: null,
|
||||||
|
interpreters: [],
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
initialize: (initialFiles: FileNode) => {
|
||||||
|
const filesWithPaths = addPaths(initialFiles);
|
||||||
|
set({
|
||||||
|
files: filesWithPaths,
|
||||||
|
expandedFolders: new Set([filesWithPaths.path || filesWithPaths.name]),
|
||||||
|
isInitialized: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Выбор файла
|
||||||
|
selectFile: (node: FileNode) => {
|
||||||
|
if (node.type === "file") {
|
||||||
|
const { openFiles, files } = get();
|
||||||
|
// Берём актуальную версию из дерева файлов
|
||||||
|
const latestFile = files ? findNode(files, node.path || "") : null;
|
||||||
|
const fileToOpen =
|
||||||
|
latestFile && latestFile.type === "file" ? latestFile : node;
|
||||||
|
|
||||||
|
if (!openFiles.find((f) => f.path === fileToOpen.path)) {
|
||||||
|
set((state) => ({ openFiles: [...state.openFiles, fileToOpen] }));
|
||||||
|
}
|
||||||
|
set({ activeFile: fileToOpen });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновление содержимого файла
|
||||||
|
updateFileContent: (content: string) => {
|
||||||
|
const { activeFile, files } = get();
|
||||||
|
if (activeFile && files) {
|
||||||
|
const updatedFile = { ...activeFile, content, dirty: true };
|
||||||
|
set({ activeFile: updatedFile });
|
||||||
|
set((state) => ({
|
||||||
|
openFiles: state.openFiles.map((f) =>
|
||||||
|
f.path === activeFile.path ? updatedFile : f,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Обновляем также в дереве файлов
|
||||||
|
const updateFileInTree = (node: FileNode): FileNode => {
|
||||||
|
if (node.path === activeFile.path) {
|
||||||
|
return updatedFile;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: node.children.map((child) => updateFileInTree(child)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
set({ files: updateFileInTree(files) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрытие файла
|
||||||
|
closeFile: (file: FileNode) => {
|
||||||
|
const { openFiles, activeFile } = get();
|
||||||
|
const newOpenFiles = openFiles.filter((f) => f.path !== file.path);
|
||||||
|
set({ openFiles: newOpenFiles });
|
||||||
|
|
||||||
|
if (activeFile?.path === file.path) {
|
||||||
|
set({ activeFile: newOpenFiles[newOpenFiles.length - 1] || null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрыть все файлы
|
||||||
|
closeAllFiles: () => {
|
||||||
|
set({ openFiles: [], activeFile: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Закрыть другие файлы
|
||||||
|
closeOtherFiles: (file: FileNode) => {
|
||||||
|
set({ openFiles: [file], activeFile: file });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить файловую систему
|
||||||
|
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => {
|
||||||
|
const { openFiles, activeFile, selectFile } = get();
|
||||||
|
|
||||||
|
set({ files: newFiles });
|
||||||
|
|
||||||
|
if (!newFiles) {
|
||||||
|
set({ openFiles: [], activeFile: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOpenFiles = openFiles
|
||||||
|
.map((f) => {
|
||||||
|
const found = findNode(newFiles, f.path || "");
|
||||||
|
return found && found.type === "file" ? found : null;
|
||||||
|
})
|
||||||
|
.filter((f): f is FileNode => f !== null);
|
||||||
|
|
||||||
|
set({ openFiles: updatedOpenFiles });
|
||||||
|
|
||||||
|
if (newFile) {
|
||||||
|
selectFile(newFile);
|
||||||
|
} else if (activeFile) {
|
||||||
|
const stillExists = findNode(newFiles, activeFile.path || "");
|
||||||
|
if (!stillExists) {
|
||||||
|
set({
|
||||||
|
activeFile: updatedOpenFiles[updatedOpenFiles.length - 1] || null,
|
||||||
|
});
|
||||||
|
} else if (stillExists.type === "file") {
|
||||||
|
set({ activeFile: stillExists });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Переключить папку
|
||||||
|
toggleFolder: (path: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const newSet = new Set(state.expandedFolders);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return { expandedFolders: newSet };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Раскрыть все папки
|
||||||
|
expandAllFolders: () => {
|
||||||
|
const { files } = get();
|
||||||
|
if (files) {
|
||||||
|
set({ expandedFolders: new Set(getAllFolderPaths(files)) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Свернуть все папки
|
||||||
|
collapseAllFolders: () => {
|
||||||
|
set({ expandedFolders: new Set() });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Автоматически раскрыть пути
|
||||||
|
autoExpandPaths: (paths: Set<string>) => {
|
||||||
|
set((state) => ({
|
||||||
|
expandedFolders: new Set([...state.expandedFolders, ...paths]),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить корень
|
||||||
|
deleteRoot: () => {
|
||||||
|
set({
|
||||||
|
files: null,
|
||||||
|
openFiles: [],
|
||||||
|
activeFile: null,
|
||||||
|
expandedFolders: new Set(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Создать новый проект
|
||||||
|
createNewProject: () => {
|
||||||
|
const newProject = addPaths(initialFiles);
|
||||||
|
set({
|
||||||
|
files: newProject,
|
||||||
|
expandedFolders: new Set([newProject.path || newProject.name]),
|
||||||
|
searchQuery: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Интерпретаторы
|
||||||
|
fetchInterpreters: async () => {
|
||||||
|
try {
|
||||||
|
const interpreters = await scriptsApi.getInterpreters();
|
||||||
|
set({ interpreters });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch interpreters:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: загрузка дерева с сервера
|
||||||
|
fetchTree: async () => {
|
||||||
|
try {
|
||||||
|
const data = await scriptsApi.getTree();
|
||||||
|
const { expandedFolders } = get();
|
||||||
|
|
||||||
|
const convertItem = (item: any): FileNode => {
|
||||||
|
const node: FileNode = {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
type: item.type === "folder" ? "folder" : "file",
|
||||||
|
content: item.content || "",
|
||||||
|
path: item.name,
|
||||||
|
interpreter_id: item.interpreter_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.type === "folder") {
|
||||||
|
node.children = [];
|
||||||
|
if (item.children && Array.isArray(item.children)) {
|
||||||
|
node.children = item.children.map((child: any) => {
|
||||||
|
const childNode = convertItem(child);
|
||||||
|
childNode.path = `${item.name}/${child.name}`;
|
||||||
|
return childNode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roots = data.map((item) => convertItem(item));
|
||||||
|
|
||||||
|
set({
|
||||||
|
files: {
|
||||||
|
name: "scripts",
|
||||||
|
type: "folder",
|
||||||
|
children: roots,
|
||||||
|
},
|
||||||
|
expandedFolders,
|
||||||
|
isInitialized: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch tree:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: создание скрипта
|
||||||
|
createScript: async (payload) => {
|
||||||
|
try {
|
||||||
|
await scriptsApi.createScript(payload);
|
||||||
|
await get().fetchTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create script:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: создание папки
|
||||||
|
createFolder: async (path: string) => {
|
||||||
|
try {
|
||||||
|
await scriptsApi.createFolder(path);
|
||||||
|
await get().fetchTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create folder:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: удаление папки
|
||||||
|
deleteFolder: async ({ path }: { path: string }) => {
|
||||||
|
try {
|
||||||
|
const { openFiles } = get();
|
||||||
|
|
||||||
|
// Закрываем все файлы, которые находятся в удаляемой папке
|
||||||
|
const folderPathPrefix = path.endsWith("/") ? path : `${path}/`;
|
||||||
|
const filesToClose = openFiles.filter(
|
||||||
|
(f) => f.path === path || f.path?.startsWith(folderPathPrefix),
|
||||||
|
);
|
||||||
|
filesToClose.forEach((f) => get().closeFile(f));
|
||||||
|
|
||||||
|
await scriptsApi.deleteFolder(path);
|
||||||
|
await get().fetchTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete folder:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: обновление скрипта
|
||||||
|
updateScript: async (id, payload) => {
|
||||||
|
try {
|
||||||
|
await scriptsApi.updateScript(id, payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update script:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: удаление скрипта
|
||||||
|
deleteScript: async (id) => {
|
||||||
|
try {
|
||||||
|
await scriptsApi.deleteScript(id);
|
||||||
|
await get().fetchTree();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete script:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API: сохранение активного файла
|
||||||
|
saveActiveFile: async () => {
|
||||||
|
const { activeFile } = get();
|
||||||
|
if (!activeFile || !activeFile.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scriptsApi.updateScript(activeFile.id, {
|
||||||
|
content: activeFile.content || "",
|
||||||
|
interpreter_id: activeFile.interpreter_id || 0,
|
||||||
|
path: activeFile.path || "",
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
activeFile: state.activeFile
|
||||||
|
? { ...state.activeFile, dirty: false }
|
||||||
|
: null,
|
||||||
|
openFiles: state.openFiles.map((f) =>
|
||||||
|
f.path === state.activeFile?.path ? { ...f, dirty: false } : f,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save file:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
setSearchQuery: (query: string) => {
|
||||||
|
set({ searchQuery: query });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSearch: () => {
|
||||||
|
set((state) => ({ showSearch: !state.showSearch }));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Контекстные меню и диалоги
|
||||||
|
setContextMenu: (menu) => set({ contextMenu: menu }),
|
||||||
|
setDialog: (dialog) => set({ dialog: dialog }),
|
||||||
|
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
|
||||||
|
|
||||||
|
// Подтверждение диалога
|
||||||
|
handleDialogConfirm: async (value: string, interpreterId?: number) => {
|
||||||
|
const { dialog, files, toggleFolder, autoExpandPaths } = get();
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
if (dialog.type === "rename" && dialog.node) {
|
||||||
|
const parentPath =
|
||||||
|
dialog.node.path?.split("/").slice(0, -1).join("/") || "";
|
||||||
|
const parentNode = parentPath ? findNode(files!, parentPath) : files;
|
||||||
|
if (
|
||||||
|
parentNode?.children?.some(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase() === value.toLowerCase() &&
|
||||||
|
c.path !== dialog.node?.path,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
alert(`"${value}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPath = dialog.node.path || dialog.node.name;
|
||||||
|
const newPath = parentPath ? `${parentPath}/${value}` : value;
|
||||||
|
|
||||||
|
// Сохраняем раскрытые папки
|
||||||
|
const savedExpandedFolders = new Set(get().expandedFolders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scriptsApi.rename({ old_path: oldPath, new_path: newPath });
|
||||||
|
await get().fetchTree();
|
||||||
|
|
||||||
|
// Восстанавливаем раскрытые папки
|
||||||
|
set({ expandedFolders: savedExpandedFolders });
|
||||||
|
|
||||||
|
// Раскрываем родительскую цепочку
|
||||||
|
const allParentPaths: string[] = [];
|
||||||
|
let current = parentPath;
|
||||||
|
while (current) {
|
||||||
|
allParentPaths.push(current);
|
||||||
|
const parts = current.split("/");
|
||||||
|
parts.pop();
|
||||||
|
current = parts.join("/");
|
||||||
|
}
|
||||||
|
autoExpandPaths(new Set(allParentPaths));
|
||||||
|
|
||||||
|
// Если переименованный файл был открыт — обновим его в openFiles
|
||||||
|
const { openFiles, activeFile } = get();
|
||||||
|
const updatedOpenFiles = openFiles.map((f) =>
|
||||||
|
f.path === oldPath ? { ...f, name: value, path: newPath } : f,
|
||||||
|
);
|
||||||
|
set({ openFiles: updatedOpenFiles });
|
||||||
|
|
||||||
|
if (activeFile?.path === oldPath) {
|
||||||
|
set({ activeFile: { ...activeFile, name: value, path: newPath } });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to rename:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ dialog: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем родительский путь
|
||||||
|
let parentPath: string;
|
||||||
|
if (!dialog.node) {
|
||||||
|
parentPath = "";
|
||||||
|
} else if (dialog.node.type === "folder") {
|
||||||
|
parentPath = dialog.node.path || dialog.node.name;
|
||||||
|
} else {
|
||||||
|
const pathParts = (dialog.node.path || dialog.node.name).split("/");
|
||||||
|
pathParts.pop();
|
||||||
|
parentPath = pathParts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие расширения
|
||||||
|
const hasExtension =
|
||||||
|
value.includes(".") && value.split(".").pop() !== value;
|
||||||
|
let finalName = value;
|
||||||
|
let isFile = false;
|
||||||
|
|
||||||
|
// Если диалог создания файла
|
||||||
|
if (dialog.type === "newFile") {
|
||||||
|
isFile = true;
|
||||||
|
// Если нет расширения — добавляем .txt
|
||||||
|
if (!hasExtension) {
|
||||||
|
finalName = `${value}.txt`;
|
||||||
|
}
|
||||||
|
} else if (dialog.type === "newFolder") {
|
||||||
|
// Если диалог создания папки — но имя с расширением, считаем файлом
|
||||||
|
if (hasExtension) {
|
||||||
|
isFile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = parentPath ? `${parentPath}/${finalName}` : finalName;
|
||||||
|
|
||||||
|
// Сохраняем раскрытые папки ДО перезагрузки дерева
|
||||||
|
const savedExpandedFolders = new Set(get().expandedFolders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создание папки
|
||||||
|
if (dialog.type === "newFolder" && !isFile) {
|
||||||
|
await scriptsApi.createFolder(fullPath);
|
||||||
|
await get().fetchTree();
|
||||||
|
|
||||||
|
// Восстанавливаем раскрытые папки
|
||||||
|
set({ expandedFolders: savedExpandedFolders });
|
||||||
|
|
||||||
|
// Собираем все пути от корня до родительской папки
|
||||||
|
const allParentPaths: string[] = [];
|
||||||
|
let current = parentPath;
|
||||||
|
while (current) {
|
||||||
|
allParentPaths.push(current);
|
||||||
|
const parts = current.split("/");
|
||||||
|
parts.pop();
|
||||||
|
current = parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раскрываем родительскую цепочку
|
||||||
|
autoExpandPaths(new Set(allParentPaths));
|
||||||
|
} else {
|
||||||
|
// Создание файла
|
||||||
|
const result = await scriptsApi.createScript({
|
||||||
|
content: "",
|
||||||
|
interpreter_id: interpreterId || 0,
|
||||||
|
path: fullPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await get().fetchTree();
|
||||||
|
|
||||||
|
// Восстанавливаем раскрытые папки
|
||||||
|
set({ expandedFolders: savedExpandedFolders });
|
||||||
|
|
||||||
|
// Собираем все пути от корня до родительской папки
|
||||||
|
const allParentPaths: string[] = [];
|
||||||
|
let current = parentPath;
|
||||||
|
while (current) {
|
||||||
|
allParentPaths.push(current);
|
||||||
|
const parts = current.split("/");
|
||||||
|
parts.pop();
|
||||||
|
current = parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раскрываем родительскую цепочку
|
||||||
|
autoExpandPaths(new Set(allParentPaths));
|
||||||
|
|
||||||
|
const createdNode: FileNode = {
|
||||||
|
id: result.id,
|
||||||
|
name: finalName,
|
||||||
|
type: "file",
|
||||||
|
content: result.content,
|
||||||
|
path: result.path,
|
||||||
|
interpreter_id: result.interpreter_id,
|
||||||
|
};
|
||||||
|
get().selectFile(createdNode);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ dialog: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление узла
|
||||||
|
handleDeleteNode: async (node: FileNode) => {
|
||||||
|
const { files } = get();
|
||||||
|
const isRootNode = node.path === files?.path;
|
||||||
|
if (isRootNode) {
|
||||||
|
get().deleteRoot();
|
||||||
|
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
||||||
|
try {
|
||||||
|
if (node.type === "folder") {
|
||||||
|
await get().deleteFolder({ path: node.path || node.name });
|
||||||
|
} else if (node.id) {
|
||||||
|
await get().deleteScript(node.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "folder";
|
||||||
|
content?: string;
|
||||||
|
children?: FileNode[];
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interpreter {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
argv: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
node: FileNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogState {
|
||||||
|
type: "newFile" | "newFolder" | "rename";
|
||||||
|
node: FileNode | null;
|
||||||
|
interpreterId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabContextMenuState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
file: FileNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTerminalStore } from "../store/useTerminalStore";
|
||||||
|
import { MdClose, MdClearAll } from "react-icons/md";
|
||||||
|
import { FiTerminal } from "react-icons/fi";
|
||||||
|
|
||||||
|
export const TerminalOutput: React.FC = () => {
|
||||||
|
const {
|
||||||
|
jobs,
|
||||||
|
isOpen,
|
||||||
|
activeJobId,
|
||||||
|
closeTerminal,
|
||||||
|
setActiveJob,
|
||||||
|
clearJobs,
|
||||||
|
removeJob,
|
||||||
|
} = useTerminalStore();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const activeJob = jobs.find((j) => j.id === activeJobId) || jobs[jobs.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1e1e1e",
|
||||||
|
borderTop: "1px solid #3e3e42",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Terminal header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
height: "35px",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
backgroundColor: "#252526",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FiTerminal size={14} color="#bbbbbb" />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#bbbbbb",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "11px",
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
TERMINAL
|
||||||
|
</span>
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#858585",
|
||||||
|
fontSize: "11px",
|
||||||
|
backgroundColor: "#3c3c3c",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jobs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearJobs}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
title="Clear all"
|
||||||
|
>
|
||||||
|
<MdClearAll size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={closeTerminal}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#858585",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<MdClose size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job tabs */}
|
||||||
|
{jobs.length > 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
backgroundColor: "#2d2d2d",
|
||||||
|
borderBottom: "1px solid #3e3e42",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<button
|
||||||
|
key={job.id}
|
||||||
|
onClick={() => setActiveJob(job.id)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 16px",
|
||||||
|
backgroundColor:
|
||||||
|
job.id === activeJobId ? "#1e1e1e" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderBottom:
|
||||||
|
job.id === activeJobId
|
||||||
|
? "2px solid #0e639c"
|
||||||
|
: "2px solid transparent",
|
||||||
|
color: job.isRunning ? "#cccccc" : "#858585",
|
||||||
|
fontSize: "12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "8px",
|
||||||
|
height: "8px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: job.isRunning ? "#4ec9b0" : "#858585",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{job.scriptPath.split("/").pop()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal output */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "12px",
|
||||||
|
fontFamily: "'Consolas', 'Courier New', monospace",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob ? (
|
||||||
|
<>
|
||||||
|
{/* Command header */}
|
||||||
|
<div style={{ marginBottom: "8px" }}>
|
||||||
|
<span style={{ color: "#6a9955" }}>$ </span>
|
||||||
|
<span style={{ color: "#cccccc" }}>
|
||||||
|
{activeJob.command.join(" ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stdin if provided */}
|
||||||
|
{activeJob.stdin && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
backgroundColor: "#2d2d2d",
|
||||||
|
borderRadius: "4px",
|
||||||
|
borderLeft: "3px solid #0e639c",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#858585" }}>stdin: </span>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: "#cccccc",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.stdin}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stdout */}
|
||||||
|
{activeJob.stdout && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#cccccc",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.stdout}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stderr */}
|
||||||
|
{activeJob.stderr && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
color: "#f44747",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.stderr}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{activeJob.isRunning ? (
|
||||||
|
<div style={{ color: "#4ec9b0" }}>⏳ Running...</div>
|
||||||
|
) : activeJob.status !== null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: activeJob.status === 0 ? "#4ec9b0" : "#f44747",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeJob.status === 0
|
||||||
|
? "✓ Process exited with code 0"
|
||||||
|
: `✗ Process exited with code ${activeJob.status}`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#858585",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiTerminal size={32} />
|
||||||
|
<span>No active jobs</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
||||||
import { FiPlusCircle, FiSend } from "react-icons/fi";
|
import { agentApiService } from "../modules/agent/api/agent.api.service";
|
||||||
|
import type { SSHAgentConfig } from "../modules/agent/ui/SSHAgentForm";
|
||||||
interface SSHAgentConfig {
|
import type {
|
||||||
user: string;
|
DeployAgentsRequest,
|
||||||
ip: string;
|
DeployResult,
|
||||||
authMethod: string;
|
} from "../modules/agent/types/agent.types";
|
||||||
sshKey?: string;
|
import {
|
||||||
password?: string;
|
FiPlusCircle,
|
||||||
extraFields: { key: string; value: string }[];
|
FiSend,
|
||||||
deployType: string;
|
FiCheck,
|
||||||
}
|
FiX,
|
||||||
|
FiAlertCircle,
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
||||||
|
agentLabel: "",
|
||||||
user: "",
|
user: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
|
port: 22,
|
||||||
authMethod: "key",
|
authMethod: "key",
|
||||||
sshKey: "",
|
sshKey: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -50,7 +54,9 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Валидация
|
// Валидация
|
||||||
const isValid = agents.every((agent) => {
|
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 === "key" && !agent.sshKey) return false;
|
||||||
if (agent.authMethod === "password" && !agent.password) return false;
|
if (agent.authMethod === "password" && !agent.password) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -66,18 +72,53 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Реальный API вызов для развертывания агентов
|
// Преобразуем данные из формы в формат API
|
||||||
console.log("Deploying agents:", agents);
|
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
|
// Вызываем API для развертывания агентов
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
const response = await agentApiService.deployAgents(deployData);
|
||||||
|
|
||||||
setSubmitMessage(
|
// Формируем сообщение о результатах
|
||||||
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`,
|
const successCount = response.results.filter(
|
||||||
);
|
(r: DeployResult) => r.success,
|
||||||
setAgents([createEmptyAgentConfig()]);
|
).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) {
|
} catch (error) {
|
||||||
setSubmitError("Ошибка при развертывании на серверах");
|
setSubmitError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Ошибка при развертывании агентов",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -162,20 +203,26 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
color: "var(--success-text)",
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: "var(--error-bg)",
|
backgroundColor: "var(--error-bg)",
|
||||||
borderColor: "var(--error-border)",
|
borderColor: "var(--error-border)",
|
||||||
color: "var(--error-text)",
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminPanel } from "@/modules/admin";
|
||||||
|
|
||||||
|
export const AdminPage = () => {
|
||||||
|
return <AdminPanel />;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|||||||
|
|
||||||
export const RegisterPage: React.FC = () => {
|
export const RegisterPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { register, isLoading, error, clearError, token } = useAuthStore();
|
const { register, isLoading, error, clearError } = useAuthStore();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -14,12 +14,7 @@ export const RegisterPage: React.FC = () => {
|
|||||||
lastName: "",
|
lastName: "",
|
||||||
});
|
});
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
|
||||||
if (token) {
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
}, [token, navigate]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -38,7 +33,17 @@ export const RegisterPage: React.FC = () => {
|
|||||||
firstName: formData.firstName,
|
firstName: formData.firstName,
|
||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
});
|
});
|
||||||
navigate("/");
|
setSuccessMessage("Аккаунт успешно создан! Теперь вы можете войти.");
|
||||||
|
setFormData({
|
||||||
|
login: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate("/auth");
|
||||||
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by store
|
// 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"
|
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
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>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl font-bold mb-2"
|
className="text-3xl font-bold mb-2"
|
||||||
@@ -109,6 +117,20 @@ export const RegisterPage: React.FC = () => {
|
|||||||
</div>
|
</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 */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Name Fields */}
|
{/* Name Fields */}
|
||||||
@@ -293,8 +315,16 @@ export const RegisterPage: React.FC = () => {
|
|||||||
className="mt-2 text-sm flex items-center gap-1"
|
className="mt-2 text-sm flex items-center gap-1"
|
||||||
style={{ color: "var(--error-text)" }}
|
style={{ color: "var(--error-text)" }}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
{passwordError}
|
{passwordError}
|
||||||
</p>
|
</p>
|
||||||
@@ -311,7 +341,8 @@ export const RegisterPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)";
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--button-primary-hover)";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -1,9 +0,0 @@
|
|||||||
import { ThemeChanger } from "@/modules/theme-changer";
|
|
||||||
|
|
||||||
export const ThemesPage = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ThemeChanger label="Выбор тем приложения" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -16,13 +16,40 @@ class ApiClient {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.axiosInstance = axios.create({
|
this.axiosInstance = axios.create({
|
||||||
baseURL: "http://194.113.106.59:8080/api/v1",
|
baseURL: "http://213.165.213.170:8080/api/v1",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
validateStatus: (status) => {
|
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("&");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// shared/api/websocket.service.ts
|
// 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 { useWebSocket, type LogMessage } from "@/shared/hooks/useWebSocket";
|
||||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
@@ -25,12 +25,12 @@ export const useWebSocketService = ({
|
|||||||
const selectedServices: string[] = [];
|
const selectedServices: string[] = [];
|
||||||
const selectedHosts: string[] = [];
|
const selectedHosts: string[] = [];
|
||||||
|
|
||||||
|
// TODO: реализовать механизм выбора сервисов
|
||||||
|
// Пока выбираем все
|
||||||
agents.forEach((agent) => {
|
agents.forEach((agent) => {
|
||||||
agent.services.forEach((service) => {
|
agent.services.forEach((service) => {
|
||||||
if (service.isSelected) {
|
selectedServices.push(service);
|
||||||
selectedServices.push(service.name);
|
selectedHosts.push(agent.token);
|
||||||
selectedHosts.push(agent.token);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,17 @@
|
|||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { ThemeToggle } from "@/modules/theme-bw/ui/ThemeToggle";
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
import React from "react";
|
import { Layout } from "@/app/providers/layout/layout";
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
interface DefaultLayoutProps {
|
export const DefaultLayout = () => {
|
||||||
children?: React.ReactNode;
|
const { token } = useAuthStore();
|
||||||
}
|
|
||||||
|
|
||||||
export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => {
|
// if (!token) {
|
||||||
const { user, logout } = useAuthStore();
|
// return <Navigate to="/auth" replace />;
|
||||||
const navigate = useNavigate();
|
// }
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
navigate("/auth");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
|
<Layout>
|
||||||
{/* Header */}
|
<Outlet />
|
||||||
<header
|
</Layout>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,3 +2,13 @@
|
|||||||
@import "./normalize.css";
|
@import "./normalize.css";
|
||||||
@import "./root.css";
|
@import "./root.css";
|
||||||
@import "./themes.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
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user