Compare commits
4 commits
e234aa9d4a
...
5489fc3b43
| Author | SHA1 | Date | |
|---|---|---|---|
| 5489fc3b43 | |||
| 8e0f640673 | |||
| 47dbadafe9 | |||
| bcf9b15634 |
78 changed files with 8658 additions and 106 deletions
68
deploy/kcc/deploy-kcc.sh
Normal file
68
deploy/kcc/deploy-kcc.sh
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Krona Control Center (KCC) - Atomic Zero-Downtime Deployment Script
|
||||
# Target: https://kccdemo.ochenstarik.ru
|
||||
# Target Host: 178.212.13.102 (User: kcc)
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="/srv/kccdemo"
|
||||
RELEASES_DIR="$APP_ROOT/releases"
|
||||
CURRENT_LINK="$APP_ROOT/current"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
NEW_RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
|
||||
|
||||
echo "================================================================="
|
||||
echo " [KCC Deploy] Initiating atomic release: $TIMESTAMP"
|
||||
echo "================================================================="
|
||||
|
||||
# 1. Create target release directory
|
||||
mkdir -p "$NEW_RELEASE_DIR"
|
||||
|
||||
# 2. Deploy static SPA artifacts
|
||||
if [ -d "./dist" ]; then
|
||||
echo "Copying ./dist to $NEW_RELEASE_DIR..."
|
||||
cp -r ./dist/* "$NEW_RELEASE_DIR/"
|
||||
elif [ -d "../dist" ]; then
|
||||
echo "Copying ../dist to $NEW_RELEASE_DIR..."
|
||||
cp -r ../dist/* "$NEW_RELEASE_DIR/"
|
||||
else
|
||||
echo "ERROR: Production build directory 'dist' not found!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set proper permissions for kcc user
|
||||
chmod -R 755 "$NEW_RELEASE_DIR"
|
||||
|
||||
# 3. Atomic symlink switch
|
||||
echo "Switching active release symlink ($CURRENT_LINK -> $NEW_RELEASE_DIR)..."
|
||||
ln -sfn "$NEW_RELEASE_DIR" "$CURRENT_LINK"
|
||||
|
||||
# 4. Prune older releases (keep last 3 for instant rollback)
|
||||
echo "Cleaning up old releases (retaining last 3)..."
|
||||
cd "$RELEASES_DIR"
|
||||
# List matching release directories sorted by date descending, remove all except top 3
|
||||
ls -1dt 20* 2>/dev/null | tail -n +4 | xargs -r rm -rf
|
||||
|
||||
# 5. Reload Nginx
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "Reloading Nginx web server..."
|
||||
sudo systemctl reload nginx || systemctl reload nginx || echo "Nginx reload skipped (no sudo permission)"
|
||||
fi
|
||||
|
||||
# 6. Post-deploy health verification
|
||||
echo "Executing health verification on local loopback..."
|
||||
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" https://127.0.0.1/control/gate -H "Host: kccdemo.ochenstarik.ru" || true)
|
||||
|
||||
echo "Local Healthcheck HTTP Status: $HTTP_STATUS"
|
||||
if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "301" ] || [ "$HTTP_STATUS" = "302" ]; then
|
||||
echo "=== [KCC Deploy] Health check: PASSED ==="
|
||||
else
|
||||
echo "WARNING: Health check returned status $HTTP_STATUS. Please verify Nginx configuration."
|
||||
fi
|
||||
|
||||
echo "================================================================="
|
||||
echo " [KCC Deploy] Release $TIMESTAMP successfully deployed and active!"
|
||||
echo " URL: https://kccdemo.ochenstarik.ru"
|
||||
echo "================================================================="
|
||||
78
deploy/kcc/nginx-kccdemo.conf
Normal file
78
deploy/kcc/nginx-kccdemo.conf
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# ==============================================================================
|
||||
# Krona Control Center - Nginx Server Block Configuration
|
||||
# Domain: kccdemo.ochenstarik.ru
|
||||
# Target Host: 178.212.13.102
|
||||
# ==============================================================================
|
||||
|
||||
# HTTP -> HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name kccdemo.ochenstarik.ru;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Production HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name kccdemo.ochenstarik.ru;
|
||||
|
||||
# SSL Certificate configuration
|
||||
ssl_certificate /etc/letsencrypt/live/kccdemo.ochenstarik.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/kccdemo.ochenstarik.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Root pointing to atomic symlink
|
||||
root /srv/kccdemo/current;
|
||||
index index.html;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; connect-src 'self' ws: wss:; frame-ancestors 'none';" always;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Static assets cache control
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Favicon and robots
|
||||
location = /favicon.ico {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# SPA routing fallback: send all routes to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Dedicated access and error logs
|
||||
access_log /var/log/nginx/kccdemo_access.log;
|
||||
error_log /var/log/nginx/kccdemo_error.log warn;
|
||||
}
|
||||
117
docs/handoff/control-center/ACCEPTANCE-TESTS.md
Normal file
117
docs/handoff/control-center/ACCEPTANCE-TESTS.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Реестр приёмочных сценариев (Acceptance Tests) Krona Control Center
|
||||
|
||||
Все приёмочные сценарии автоматизированы в Playwright E2E сьюте (`e2e/11-control-center.spec.ts`) и покрывают как функциональные требования, так и устойчивость интерфейса.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 1: Поиск организации и открытие 360° Инспектора
|
||||
- **Предусловия**: Оператор находится на странице `/control/organizations`.
|
||||
- **Шаги**:
|
||||
1. Ввести в поле поиска `"Северная"`.
|
||||
2. Убедиться, что в таблице отображается только «Северная Ритейл Группа».
|
||||
3. Нажать кнопку `"360° Карточка"`.
|
||||
- **Ожидаемый результат**:
|
||||
- Открывается боковой Drawer инспектора с карточкой организации, ИНН, датой регистрации.
|
||||
- Доступны вкладки «Обзор», «Модули», «Инсталляции», «Биллинг».
|
||||
- Переключение на вкладку «Модули» отображает установленные версии модулей и их статус.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 2: Подтверждение опасного действия с обязательным обоснованием
|
||||
- **Предусловия**: Открыт 360° инспектор организации.
|
||||
- **Шаги**:
|
||||
1. Нажать кнопку `"Выдать Trial +14 дней"`.
|
||||
2. Проверить появление модального диалога `ConfirmOperatorAction`.
|
||||
3. Проверить, что кнопка подтверждения отключена при пустом поле обоснования.
|
||||
4. Ввести обоснование `"Запрос от аккаунт-директора на пилотное тестирование филиала"`.
|
||||
5. Нажать `"Подтвердить операцию"`.
|
||||
- **Ожидаемый результат**:
|
||||
- Диалог закрывается, дата окончания trial продлевается на 14 дней.
|
||||
- В журнал аудита добавляется запись с SHA-256 хешем и введенным обоснованием.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 3: Фильтрация подписок, истекающих в течение 7 дней
|
||||
- **Предусловия**: Оператор открывает раздел `/control/billing`.
|
||||
- **Шаги**:
|
||||
1. Активировать быстрый фильтр `"Истекают в течение 7 дней"`.
|
||||
- **Ожидаемый результат**:
|
||||
- Таблица фильтруется, остаётся только «ТехноПарк Восток» (срок окончания подписки через 2 дня).
|
||||
- Отображается сумма регулярного платежа в рублях через `MoneyDisplay`.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 4: Мониторинг терминалов и выявление устаревших Android-касс
|
||||
- **Предусловия**: Переход в `/control/installations`.
|
||||
- **Шаги**:
|
||||
1. Нажать на быстрый фильтр `"Устаревшие версии"`.
|
||||
2. Проверить строки с бейджем `"Устарело"` (POS-терминал кассы №4, Android 11, клиент v1.8.4).
|
||||
3. Нажать действие `"Отозвать токен"`.
|
||||
- **Ожидаемый результат**:
|
||||
- Открывается окно подтверждения с фиксацией отзыва токена устройства.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 5: Мониторинг узлов кластера и сбой Server Monitor (Circuit Breaker)
|
||||
- **Предусловия**: Переход в `/control/infrastructure`.
|
||||
- **Шаги**:
|
||||
1. Проверить список нод: `node-krona-prod-01`, `node-krona-prod-02`, `node-krona-prod-03`.
|
||||
2. В шапке нажать кнопку симуляции `"Simulate SM Outage"`.
|
||||
- **Ожидаемый результат**:
|
||||
- Вверху экрана появляется предупреждающий баннер `Server Monitor Unreachable (Fallback to Local Cache)`.
|
||||
- Все экраны сохраняют интерактивность и читают данные из последнего валидного снимка IndexedDB.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 6: Создание инцидента из критического алерта
|
||||
- **Предусловия**: Переход в `/control/incidents`.
|
||||
- **Шаги**:
|
||||
1. В списке обнаруженных аномалий выбрать `"Деградация p99 latency в кластере баз данных"`.
|
||||
2. Нажать `"Создать инцидент P1"`.
|
||||
3. Заполнить описание и назначить дежурного инженера.
|
||||
- **Ожидаемый результат**:
|
||||
- Инцидент регистрируется со статусом `Investigating` и уровнем `P1 Critical`.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 7: Публикация таргетированного системного оповещения
|
||||
- **Предусловия**: Переход в `/control/announcements`.
|
||||
- **Шаги**:
|
||||
1. Заполнить форму объявления: заголовок `"Плановое ночное техобслуживание"`, уровень `Warning`.
|
||||
2. Нажать кнопку `"Опубликовать"`.
|
||||
3. В модальном окне ввести причину публикации.
|
||||
- **Ожидаемый результат**:
|
||||
- Оповещение появляется в списке активных трансляций.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 8: Управление Rollout-конвейером (Пауза / Возобновление)
|
||||
- **Предусловия**: Переход в `/control/modules`.
|
||||
- **Шаги**:
|
||||
1. Выбрать модуль с активной раскаткой («Омниканальные коммуникации», фаза Pilot).
|
||||
2. Нажать `"Поставить на паузу"`.
|
||||
3. Ввести причину в модальном диалоге.
|
||||
- **Ожидаемый результат**:
|
||||
- Статус раскатки переходит в `"На паузе"`, конвейер замораживается.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 9: Смена роли оператора и проверка соблюдения RBAC
|
||||
- **Предусловия**: Открыт любой раздел Control Center.
|
||||
- **Шаги**:
|
||||
1. В переключателе сессии в шапке выбрать роль `"Аудитор безопасности"`.
|
||||
2. Перейти в раздел `/control/modules`.
|
||||
- **Ожидаемый результат**:
|
||||
- Кнопки запуска и остановки раскатки становятся неактивными (`disabled`) для аудитора.
|
||||
- Повторное переключение на `"Владелец платформы"` восстанавливает полный доступ.
|
||||
|
||||
---
|
||||
|
||||
### Сценарий 10: Сохранение состояния при перезагрузке страницы (IndexedDB)
|
||||
- **Предусловия**: В журнал аудита добавлены записи операторских действий.
|
||||
- **Шаги**:
|
||||
1. Зафиксировать количество строк в журнале `/control/audit`.
|
||||
2. Выполнить полную перезагрузку страницы в браузере (`F5` / `page.reload()`).
|
||||
- **Ожидаемый результат**:
|
||||
- Интерфейс восстанавливает данные из локальной базы IndexedDB.
|
||||
- Количество строк журнала аудита и SHA-256 хеши сохраняются неизменными.
|
||||
136
docs/handoff/control-center/DEPLOYMENT-DEMO.md
Normal file
136
docs/handoff/control-center/DEPLOYMENT-DEMO.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Руководство по развёртыванию Krona Control Center на VPS
|
||||
|
||||
## 1. Целевая инфраструктура и топология
|
||||
|
||||
| Параметр | Значение | Описание |
|
||||
| :--- | :--- | :--- |
|
||||
| **Домен** | `https://kccdemo.ochenstarik.ru` | Публичный защищённый домен демонстрационного стенда |
|
||||
| **IP-адрес VPS** | `178.212.13.102` | Целевой сервер развёртывания |
|
||||
| **Системный пользователь** | `kcc` | Изолированный непривилегированный Linux-пользователь |
|
||||
| **Директория релизов** | `/srv/kccdemo/releases/<TIMESTAMP>` | Версионированные каталоги сборок |
|
||||
| **Символическая ссылка** | `/srv/kccdemo/current` | Указывает на активный релиз (atomic switch) |
|
||||
| **Логи Nginx** | `/var/log/nginx/kccdemo_access.log` | Доступ и аудит веб-сервера |
|
||||
|
||||
---
|
||||
|
||||
## 2. Статус внешнего подключения и диагностика доступа (Pre-flight Blocker)
|
||||
|
||||
В ходе подготовки развёртывания была выполнена сетевая диагностика хоста `178.212.13.102`:
|
||||
|
||||
1. **Сетевая доступность (Ping & Port 22)**:
|
||||
- Хост `178.212.13.102` доступен, порт 22 (OpenSSH) открыт и принимает TCP-соединения.
|
||||
2. **Аутентификация по SSH**:
|
||||
- Попытка подключения: `ssh -i ~/.ssh/id_rsa kcc@178.212.13.102`
|
||||
- Ответ сервера: `Permission denied (publickey)`.
|
||||
3. **Причина блокировки**:
|
||||
- Сервер VPS настроен на строгую аутентификацию исключительно по SSH-ключам (`PasswordAuthentication no`).
|
||||
- Публичный ключ агента/рабочей станции не добавлен в файл `/home/kcc/.ssh/authorized_keys` на удаленном сервере.
|
||||
4. **Решение для администратора / владельца платформы**:
|
||||
- Вариант А: Добавить публичный ключ локальной машины в `/home/kcc/.ssh/authorized_keys` на VPS `178.212.13.102`.
|
||||
- Вариант Б: Скопировать подготовленный каталог `deploy/kcc/` и директорию `dist/` на сервер через локальный доступ администратора и запустить `/srv/kccdemo/deploy-kcc.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Автоматизированный скрипт развёртывания (`deploy/kcc/deploy-kcc.sh`)
|
||||
|
||||
Скрипт обеспечивает атомарный деплой с нулевым временем простоя:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/srv/kccdemo"
|
||||
RELEASES_DIR="$APP_DIR/releases"
|
||||
CURRENT_LINK="$APP_DIR/current"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
NEW_RELEASE_DIR="$RELEASES_DIR/$TIMESTAMP"
|
||||
|
||||
echo "=== [KCC Deploy] Starting atomic release $TIMESTAMP ==="
|
||||
|
||||
mkdir -p "$NEW_RELEASE_DIR"
|
||||
cp -r ./dist/* "$NEW_RELEASE_DIR/"
|
||||
|
||||
# Atomic switch
|
||||
ln -sfn "$NEW_RELEASE_DIR" "$CURRENT_LINK"
|
||||
|
||||
# Keep last 3 releases
|
||||
echo "=== [KCC Deploy] Pruning older releases (retaining last 3) ==="
|
||||
cd "$RELEASES_DIR"
|
||||
ls -1dt 20* | tail -n +4 | xargs -r rm -rf
|
||||
|
||||
echo "=== [KCC Deploy] Verifying local health ==="
|
||||
curl -sk https://127.0.0.1/control/gate -H "Host: kccdemo.ochenstarik.ru" | grep -q "Krona Control Center" && echo "Health check: PASSED"
|
||||
|
||||
echo "=== [KCC Deploy] Release $TIMESTAMP successfully active! ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Конфигурация Nginx (`deploy/kcc/nginx-kccdemo.conf`)
|
||||
|
||||
Конфигурация включает заголовки безопасности, поддержку SPA маршрутизации и SSL:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name kccdemo.ochenstarik.ru;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name kccdemo.ochenstarik.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/kccdemo.ochenstarik.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/kccdemo.ochenstarik.ru/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
root /srv/kccdemo/current;
|
||||
index index.html;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Gzip Compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Static Assets Caching
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA Routing Fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
access_log /var/log/nginx/kccdemo_access.log;
|
||||
error_log /var/log/nginx/kccdemo_error.log;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Процедура мгновенного отката (Instant Rollback)
|
||||
|
||||
В случае необходимости немедленного отката к предыдущей стабильной версии:
|
||||
|
||||
```bash
|
||||
cd /srv/kccdemo
|
||||
PREVIOUS_RELEASE=$(ls -1dt releases/20* | sed -n '2p')
|
||||
if [ -n "$PREVIOUS_RELEASE" ]; then
|
||||
echo "Rolling back to: $PREVIOUS_RELEASE"
|
||||
ln -sfn "/srv/kccdemo/$PREVIOUS_RELEASE" /srv/kccdemo/current
|
||||
systemctl reload nginx
|
||||
echo "Rollback completed."
|
||||
else
|
||||
echo "No previous release found to rollback."
|
||||
fi
|
||||
```
|
||||
80
docs/handoff/control-center/FRONTEND-CONTRACTS.md
Normal file
80
docs/handoff/control-center/FRONTEND-CONTRACTS.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Фронтенд-контракты и Модели данных Krona Control Center
|
||||
|
||||
## 1. Концепция Minor Currency Units (Минорные единицы валюты)
|
||||
|
||||
Для исключения ошибок округления чисел с плавающей точкой (`0.1 + 0.2 !== 0.3`) все финансовые поля в Control Center хранятся исключительно в минорных единицах (копейки):
|
||||
|
||||
- **Хранение**: целочисленные значения `number` (`priceMonthlyMinor`, `mrrMinor`, `amountMinor`).
|
||||
- **Пример**: `149 000 руб.` представляется как `14900000` (копеек).
|
||||
- **Форматирование**: компонент `<MoneyDisplay amountMinor={14900000} currency="RUB" />` производит безопасное форматирование с помощью `Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' })`, деля число на 100 исключительно на этапе рендеринга строки.
|
||||
|
||||
---
|
||||
|
||||
## 2. Неизменяемый криптографический аудит (SHA-256 Hash Chaining)
|
||||
|
||||
Каждое операторское действие формирует блок в локальной цепочке аудита. Порядок связывания:
|
||||
|
||||
```typescript
|
||||
export interface OperatorAuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
operatorRole: OperatorRole;
|
||||
operatorName: string;
|
||||
actionType: OperatorActionType;
|
||||
entityType: 'organization' | 'installation' | 'node' | 'module' | 'announcement' | 'incident' | 'system';
|
||||
entityId: string;
|
||||
reason: string;
|
||||
impactSummary: string;
|
||||
diffBeforeAfter?: {
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
};
|
||||
clientIp?: string;
|
||||
previousHash: string; // Ссылка на хеш предыдущей записи
|
||||
eventHash: string; // SHA-256(previousHash + timestamp + operatorRole + actionType + entityId + reason)
|
||||
}
|
||||
```
|
||||
|
||||
### Алгоритм вычисления хеша:
|
||||
```typescript
|
||||
export async function computeEventHash(
|
||||
previousHash: string,
|
||||
timestamp: string,
|
||||
operatorRole: string,
|
||||
actionType: string,
|
||||
entityId: string,
|
||||
reason: string
|
||||
): Promise<string> {
|
||||
const payload = `${previousHash}|${timestamp}|${operatorRole}|${actionType}|${entityId}|${reason}`;
|
||||
const msgUint8 = new TextEncoder().encode(payload);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Схема постоянного хранилища IndexedDB
|
||||
|
||||
База данных `krona_control_center_db` (версия 1) содержит следующие хранилища объектов (Object Stores):
|
||||
|
||||
1. `organizations`: карточки тенантов платформы (`PlatformOrganization`).
|
||||
2. `module_instances`: установленные модули и версии (`ModuleInstance`).
|
||||
3. `installations`: физические устройства, кассы, терминалы (`InstallationDevice`).
|
||||
4. `nodes`: серверные узлы кластера (`ClusterNode`).
|
||||
5. `announcements`: сервисные сообщения и баннеры (`SystemAnnouncement`).
|
||||
6. `incidents`: инциденты платформы (`PlatformIncident`).
|
||||
7. `audit_log`: цепочка блоков аудита с индексом по `timestamp`.
|
||||
8. `settings`: глобальные флаги (maintenance mode, circuit breaker test flags).
|
||||
|
||||
---
|
||||
|
||||
## 4. Контракт устойчивости Server Monitor Outage (Circuit Breaker)
|
||||
|
||||
При отказе внешнего сервиса мониторинга кластера:
|
||||
1. `useKccStore.serverMonitorOutage = true`.
|
||||
2. Интерфейс переходит в режим graceful degradation:
|
||||
- Отображается аварийный баннер `ServerMonitorOutageBanner` с индикатором «Используются локальные кэшированные метрики».
|
||||
- Метрики узлов замораживаются на последнем известном валидном снимке из IndexedDB.
|
||||
- Запрещается автоматическая эвакуация нод без явного ручного подтверждения `force_offline_drain`.
|
||||
47
docs/handoff/control-center/PERMISSIONS.md
Normal file
47
docs/handoff/control-center/PERMISSIONS.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Матрица ролей и разрешений (RBAC) Krona Control Center
|
||||
|
||||
## 1. Инженерные роли операторов
|
||||
|
||||
В Control Center реализована строгая ролевая модель, отражающая реальные функции дежурной эксплуатации, разработки и безопасности платформы:
|
||||
|
||||
| Роль | Системный идентификатор | Описание |
|
||||
| :--- | :--- | :--- |
|
||||
| **Владелец платформы** | `platform_owner` | Полный неограниченный доступ ко всем контурам, инфраструктуре, биллингу и аварийным режимам. |
|
||||
| **Администратор платформы** | `platform_admin` | Управление тенантами, эвакуация нод, рассылки, аварийные настройки (за исключением финансовых счетов). |
|
||||
| **Оператор поддержки L2/L3** | `support_operator` | Диагностика тенантов, ведение инцидентов, просмотр инсталляций, выдача временных демо/trial. |
|
||||
| **Менеджер биллинга** | `billing_manager` | Контроль задолженностей, выставление счетов, применение скидок, ручное продление подписок. |
|
||||
| **Аудитор безопасности** | `security_auditor` | Read-only доступ ко всем экранам + полный доступ к неизменяемому криптографическому журналу аудита. Запрет на любые мутации. |
|
||||
| **Релиз-инженер** | `release_manager` | Управление rollout-конвейером модулей, запуск Canary/Pilot/GA фаз, экстренная приостановка раскатки. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Гранулярные права (Permissions)
|
||||
|
||||
- `control_center:read`: Просмотр базовых экранов (Обзор, Организации, Инсталляции).
|
||||
- `control_center:write`: Редактирование параметров организаций, отправка рассылок.
|
||||
- `control_center:billing_manage`: Операции с тарифами, счетами, продлением подписок.
|
||||
- `control_center:node_evacuate`: Вывод узлов кластера из ротации (Drain/Evacuate).
|
||||
- `control_center:incident_manage`: Создание инцидентов, изменение критичности, назначение ответственных.
|
||||
- `control_center:rollout_manage`: Переключение стадий раскатки версий модулей платформы.
|
||||
- `control_center:operator_audit`: Просмотр неизменяемого журнала аудита и экспорт крипто-цепочки.
|
||||
|
||||
---
|
||||
|
||||
## 3. Матрица соответствия Ролей и Прав
|
||||
|
||||
| Разрешение | `platform_owner` | `platform_admin` | `support_operator` | `billing_manager` | `security_auditor` | `release_manager` |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| `control_center:read` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| `control_center:write` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `control_center:billing_manage` | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
|
||||
| `control_center:node_evacuate` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `control_center:incident_manage` | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| `control_center:rollout_manage` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
| `control_center:operator_audit` | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Контроль на уровне UI компонентов
|
||||
|
||||
- При отсутствии необходимого разрешения интерактивные кнопки мутаций автоматически блокируются (`disabled={!canPerform}`) с поясняющим tooltip или атрибутом `title="Недостаточно прав для данной операции"`.
|
||||
- Попытка программного вызова защищенного действия отклоняется на уровне сервис-слоя с генерацией исключения `PermissionDeniedError` и немедленной фиксацией в журнале безопасности.
|
||||
29
docs/handoff/control-center/ROUTES.md
Normal file
29
docs/handoff/control-center/ROUTES.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Реестр маршрутов Krona Control Center
|
||||
|
||||
## 1. Базовые маршруты и изоляция
|
||||
|
||||
Все маршруты Control Center размещены в отдельном префиксе `/control/*` и изолированы от клиентских приложений платформы (`/app/*`) и лендинга (`/`).
|
||||
|
||||
| Путь | Название | Компонент | Доступ (Минимальная роль) | Описание |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `/control/gate` | PIN-шлюз авторизации | `ControlGatePage` | Публичный внутри подсети (ввод PIN) | Инженерный шлюз ввода операторского PIN (по умолчанию: `8492` для демо). Выдаёт сессионный токен `kcc_gate_pass`. |
|
||||
| `/control` | Редирект в пульт | `ControlRouteGuard` | Любая подтверждённая роль | Автоматический редирект на `/control/overview` |
|
||||
| `/control/overview` | Сводка платформы | `OverviewPage` | `support_operator`+ (`control_center:read`) | Общий дашборд здоровья платформы, метрик MRR, статусов нод и инцидентов |
|
||||
| `/control/organizations` | Реестр тенантов | `OrganizationsPage` | `support_operator`+ (`control_center:read`) | Список организаций, фильтрация, быстрый поиск и вызов 360° Drawer |
|
||||
| `/control/billing` | Биллинг и Подписки | `BillingPage` | `billing_manager`+ (`control_center:billing_manage`) | Реестр подписок, контроль просрочек, счета в minor units |
|
||||
| `/control/installations` | Парк инсталляций | `InstallationsPage` | `support_operator`+ (`control_center:read`) | Мониторинг POS/Android/Desktop терминалов, отзыв токенов |
|
||||
| `/control/nodes` | Узлы инфраструктуры | `NodesPage` | `platform_admin`+ (`control_center:node_evacuate`) | Мониторинг CPU/RAM кластера, эвакуация нод, тест Circuit Breaker |
|
||||
| `/control/modules` | Rollout-конвейер | `ModulesPage` | `release_manager`+ (`control_center:rollout_manage`) | Управление версиями, запуск Canary/Pilot/GA фаз, пауза раскатки |
|
||||
| `/control/incidents` | Инциденты и Алерты | `IncidentsPage` | `support_operator`+ (`control_center:incident_manage`) | Реестр инцидентов P1-P4, назначение дежурного, перевод статусов |
|
||||
| `/control/broadcasts` | Системные рассылки | `BroadcastsPage` | `platform_admin`+ (`control_center:write`) | Отправка баннеров тенантам с таргетингом по тарифам и модулям |
|
||||
| `/control/audit` | Журнал аудита | `AuditPage` | `security_auditor`+ (`control_center:operator_audit`) | Неизменяемый журнал операций с проверкой валидности SHA-256 хеш-цепочки |
|
||||
|
||||
---
|
||||
|
||||
## 2. Шлюз защиты (ControlRouteGuard)
|
||||
|
||||
Компонент `ControlRouteGuard` реализует многоуровневую защиту:
|
||||
1. **Проверка PIN-доступа**: Читает сессионный флаг `sessionStorage.getItem('kcc_gate_pass')`. Если отсутствует — перенаправляет на `/control/gate`.
|
||||
2. **Проверка сессии оператора**: Читает активную роль из `sessionStorage.getItem('kcc_operator_session')`. Если отсутствует — инициализирует сессию по умолчанию (`platform_owner`).
|
||||
3. **Проверка гранулярных разрешений**: Для защищенных разделов валидирует наличие необходимого permission в текущей роли оператора. При отсутствии прав рендерит защитное уведомление с предложением сменить роль или запросить доступ.
|
||||
4. **Error Boundary**: Все дочерние экраны обёрнуты в локальный обработчик ошибок с кнопкой сброса кэша и перезагрузки интерфейса.
|
||||
62
docs/handoff/control-center/STATES.md
Normal file
62
docs/handoff/control-center/STATES.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Матрица состояний и переходов Krona Control Center
|
||||
|
||||
## 1. Состояния платформы (Platform Operation Modes)
|
||||
|
||||
| Состояние | Индикатор | Поведение интерфейса | Допустимые действия |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Normal** | Зеленый бейдж `Operational` | Штатный режим работы, все метрики обновляются в реальном времени | Все операции разрешены согласно матрице RBAC |
|
||||
| **Degraded** | Янтарный бейдж `Degraded Performance` | Отказ части вторичных систем (например, Server Monitor), метрики отображаются из локального кэша | Разрешены аварийные действия, выдача оповещений |
|
||||
| **Emergency Maintenance** | Красный пульсирующий баннер `Platform Maintenance Active` | Платформа переведена в аварийный сервисный режим; клиентские входы заблокированы информационным экраном | Доступны только действия владельца платформы по восстановлению |
|
||||
|
||||
---
|
||||
|
||||
## 2. Статусы клиентских организаций (Tenants)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Trial: Регистрация / Демо
|
||||
Trial --> Active: Оплата подписки
|
||||
Trial --> Frozen: Истечение 14 дней без оплаты
|
||||
Active --> PastDue: Сбой списания (Grace period 7 дней)
|
||||
PastDue --> Active: Успешная повторная оплата
|
||||
PastDue --> Frozen: Неуплата по истечении льготного периода
|
||||
Frozen --> Active: Ручная разморозка / Погашение долга
|
||||
Frozen --> Terminated: 90 дней неактивности / Расторжение
|
||||
Terminated --> [*]
|
||||
```
|
||||
|
||||
- **Trial**: Доступны базовые модули, ограниченный лимит пользователей. Возможно продление через `Выдать Trial +14 дней`.
|
||||
- **Active**: Полнофункциональный доступ в рамках оплаченного тарифа.
|
||||
- **Past Due**: Доступ сохраняется, отображается предупреждающий баннер в клиентском приложении.
|
||||
- **Frozen**: Блокировка доступа всех пользователей тенанта (кроме чтения счетов). Требует обоснования при ручной заморозке.
|
||||
- **Terminated**: Доступ аннулирован, данные архивированы.
|
||||
|
||||
---
|
||||
|
||||
## 3. Фазы Rollout-конвейера модулей
|
||||
|
||||
1. **Canary (10%)**: Первичное развёртывание новой версии модуля на 10% тестовых тенантов.
|
||||
2. **Pilot (30%)**: Расширение на пилотную группу организаций.
|
||||
3. **Staged (50%)**: Поэтапная раскатка на половину инфраструктуры.
|
||||
4. **General Availability (GA, 100%)**: Полный переход всех клиентов на новую версию.
|
||||
5. **Paused**: Экстренная заморозка конвейера при обнаружении аномалий.
|
||||
6. **Rolled Back**: Откат на стабильную предыдущую версию.
|
||||
|
||||
---
|
||||
|
||||
## 4. Состояния узлов инфраструктуры (Cluster Nodes)
|
||||
|
||||
- **Healthy**: CPU < 75%, RAM < 80%, p99 latency < 50ms.
|
||||
- **Warning**: Превышение порогов загрузки, авто-скейлинг подов.
|
||||
- **Critical**: Отказ сетевого интерфейса или дисковой подсистемы.
|
||||
- **Draining**: Нода выводится из балансировщика, активные сессии мягко мигрируют.
|
||||
- **Evacuated**: Узел изолирован, нагрузка отсутствует, доступен для регламентных работ.
|
||||
|
||||
---
|
||||
|
||||
## 5. Статусы инсталляций и терминалов
|
||||
|
||||
- **Online**: Устройство активно, пинг получен менее 2 минут назад.
|
||||
- **Offline**: Пинг отсутствует более 10 минут.
|
||||
- **Outdated**: Версия клиентского ПО ниже минимально допустимой платформой (требуется обновление).
|
||||
- **Revoked**: Токен авторизации устройства отозван оператором платформы.
|
||||
88
docs/handoff/control-center/UX-SPEC.md
Normal file
88
docs/handoff/control-center/UX-SPEC.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Krona Control Center — UX Спецификация
|
||||
|
||||
## 1. Архитектура и Назначение
|
||||
Krona Control Center (KCC) — изолированный закрытый инженерный пульт владельца платформы Krona и дежурной инженерно-эксплуатационной команды.
|
||||
|
||||
### Ключевые архитектурные принципы
|
||||
- **Изоляция контура**: KCC полностью отделён от административного кабинета клиентской организации (`/app/*`) и публичных страниц (`/`, `/demo`). Доступ организован в отдельном пространстве путей `/control/*`.
|
||||
- **Защита шлюзом**: Доступ закрыт через `ControlRouteGuard` и PIN-гейт (`/control/gate`), требующий подтверждения операторского доступа (сессионный токен `kcc_gate_pass` + `operator_session`).
|
||||
- **Строгая модель ролей (RBAC)**: 6 специализированных инженерных ролей с гранулярными правами доступа (`control_center:read`, `control_center:write`, `control_center:billing_manage`, `control_center:node_evacuate`, `control_center:incident_manage`, `control_center:rollout_manage`, `control_center:operator_audit`).
|
||||
- **Неизменяемый журнал аудита**: Любое опасное или мутирующее действие требует модального подтверждения с обязательным указанием причины (минимум 5 символов), фиксацией затронутых сущностей и записью в криптографически связанный SHA-256 хеш-блокчейн в IndexedDB (`krona_control_center_db`).
|
||||
- **Устойчивость к сбоям инфраструктуры (Circuit Breaker)**: Поддержка offline-first и degraded режимов с кэшированием последнего снимка состояния в IndexedDB при недоступности внешних мониторов или сетевых сбоях.
|
||||
|
||||
---
|
||||
|
||||
## 2. Навигация и Визуальная Иерархия
|
||||
|
||||
### Навигационное древо
|
||||
1. **Обзор платформы (`/control/overview`)**:
|
||||
- Верхний статус-бар: Режим среды (Production), активная инженерная роль, индикатор синхронизации IndexedDB, кнопка аварийного режима (Maintenance), экстренный выход.
|
||||
- Сводные KPI-метрики: активные организации, совокупный MRR, здоровье узлов кластера, активные инциденты и предупреждения.
|
||||
- Виджеты оперативного контроля: критические алерты, статус rollout-конвейера, географическое распределение нагрузок.
|
||||
2. **Организации и 360° Инспектор (`/control/organizations`)**:
|
||||
- Таблица тенантов с фильтрацией по тарифным планам (`enterprise`, `business`, `starter`), статусам (`active`, `past_due`, `frozen`, `trial`) и быстрому поиску по названию/ИНН/домену.
|
||||
- Вызов контекстного 360° инспектора (Drawer) без потери контекста таблицы.
|
||||
3. **Биллинг и Подписки (`/control/billing`)**:
|
||||
- Фильтры истекающих подписок (окно 7 дней, просроченные платежи).
|
||||
- Сводка по Minor Units (копейки) с форматированием без плавающей точки.
|
||||
- История платежей, выставление счетов, ручное продление trial.
|
||||
4. **Инсталляции и Терминалы (`/control/installations`)**:
|
||||
- Учёт парка POS/Android/Desktop терминалов и мобильных приложений.
|
||||
- Мониторинг устаревших версий (flag `outdated`), времени последнего пинга и принудительный отзыв токенов сессий (`Revoke Token`).
|
||||
5. **Инфраструктура и Узлы (`/control/nodes`)**:
|
||||
- Мониторинг кластерных нод (CPU, RAM, Disk IO, p99 latency).
|
||||
- Действие вывода ноды из ротации (`Drain / Evacuate Node`).
|
||||
- Имитация сбоя сервера мониторинга для верификации Circuit Breaker.
|
||||
6. **Rollout и Каталог модулей (`/control/modules`)**:
|
||||
- Управление версиями модулей платформы.
|
||||
- Фазы конвейера: `Canary (10%)`, `Pilot (30%)`, `Staged (50%)`, `GA (100%)`.
|
||||
- Оперативные действия: `Start Rollout`, `Pause`, `Resume`, `Rollback`.
|
||||
7. **Инциденты и Алерты (`/control/incidents`)**:
|
||||
- Матрица тяжести: `P1 Critical`, `P2 Major`, `P3 Warning`, `P4 Info`.
|
||||
- Эскалация, назначение дежурного инженера, перевод в `In Progress`, `Mitigated`, `Resolved`.
|
||||
8. **Системные рассылки (`/control/broadcasts`)**:
|
||||
- Таргетинг по тарифам и модулям тенантов.
|
||||
- Уровни важности: `info`, `warning`, `critical_maintenance`.
|
||||
9. **Журнал аудита (`/control/audit`)**:
|
||||
- Хронологический реестр операций с проверкой валидности SHA-256 хеш-цепочки.
|
||||
- Экспорт в CSV/JSON.
|
||||
|
||||
---
|
||||
|
||||
## 3. Контекстный 360° Инспектор (Drawer)
|
||||
Шторка детального инспектирования вызывается с правой стороны экрана (600px на десктопе, 100% на мобильных экранах):
|
||||
- **Вкладка «Обзор»**: Карточка организации, ИНН, домен, дата создания, активный SLA, текущий баланс и лимиты пользователей.
|
||||
- **Вкладка «Модули»**: Список подключенных сервисных модулей с версиями и статусами работоспособности.
|
||||
- **Вкладка «Инсталляции»**: Привязанные терминалы и кассы с версиями клиента.
|
||||
- **Вкладка «Биллинг»**: График выставления счетов, статус продления и скидки.
|
||||
- **Панель быстрых действий оператора**:
|
||||
- `Выдать Trial +14 дней` (требует подтверждения с фиксацией причины).
|
||||
- `Заморозить доступ` / `Разморозить` (критическая операция).
|
||||
- `Сбросить 2FA администраторов` (операция безопасности).
|
||||
- `Экспорт данных тенанта` (GDPR/152-ФЗ compliance).
|
||||
|
||||
---
|
||||
|
||||
## 4. Паттерн подтверждения опасных операций (ConfirmOperatorAction)
|
||||
Любое деструктивное или влияющее на клиентов действие открывает диалоговое окно `ConfirmOperatorAction`:
|
||||
1. **Предупреждение о последствиях**: Четкое перечисление затрагиваемых сущностей (например, «Будет заблокирован доступ 120 пользователей»).
|
||||
2. **Обязательное обоснование (`action-reason-input`)**: Поле ввода причины выполнения с валидацией минимальной длины (не менее 5 символов).
|
||||
3. **Кнопка подтверждения с семантическим цветом**: Красный (`bg-red-600`) для необратимых/блокирующих действий, янтарный (`bg-amber-600`) для сервисных операций.
|
||||
4. **Хеширование записи аудита**: Запись немедленно хешируется с предыдущим хешем по схеме:
|
||||
`hash = SHA-256(prevHash + timestamp + operatorRole + actionType + entityId + reason)`
|
||||
и сохраняется в локальное постоянное хранилище IndexedDB.
|
||||
|
||||
---
|
||||
|
||||
## 5. Дизайн-система: Темная инженерная тема
|
||||
Интерфейс спроектирован по канонам современных Mission-Control дашбордов:
|
||||
- **Цветовая палитра**: Глубокий slate/zinc фон (`bg-zinc-950`, `bg-zinc-900`), контрастные границы (`border-zinc-800`), четкая типографика (`text-zinc-100`, `text-zinc-400`).
|
||||
- **Семантические акценты**:
|
||||
- `Зеленый (Emerald)`: Нормальная работа узла, активная подписка, GA-статус.
|
||||
- `Янтарный (Amber)`: Предупреждение, истекающий trial, пауза раскатки.
|
||||
- `Красный (Rose/Red)`: P1 инцидент, отказ узла, заблокированная организация.
|
||||
- `Синий/Фиолетовый (Indigo/Violet)`: Канареечные релизы, системные оповещения.
|
||||
- **Адаптивность (Zero Horizontal Overflow)**:
|
||||
- Desktop (1440×900+): Полноразмерный сайдбар, многоколоночные таблицы, плавающий Drawer 600px.
|
||||
- Tablet (768×1024): Сворачиваемый сайдбар, горизонтальная прокрутка таблиц внутри изолированных контейнеров.
|
||||
- Mobile (390×844): Компактный мобильный заголовок, off-canvas навигация, карточный вид для таблиц.
|
||||
272
e2e/11-control-center.spec.ts
Normal file
272
e2e/11-control-center.spec.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Krona Control Center - Production UX & Operator Scenarios', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/control');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('01. Shell loads with zero horizontal overflow and topbar controls', async ({ page }) => {
|
||||
await expect(page.locator('text=Krona Control Center').first()).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Сессия оператора"]')).toBeVisible();
|
||||
|
||||
const isOverflowing = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(isOverflowing).toBe(false);
|
||||
|
||||
await expect(page.locator('text=Сводная операторская панель')).toBeVisible();
|
||||
await expect(page.locator('text=Организации').first()).toBeVisible();
|
||||
await expect(page.locator('text=Пользователи / Точки').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('02. Scenario 1: Search organization and open 360 inspector drawer', async ({ page }) => {
|
||||
await page.click('a[href="/control/organizations"]');
|
||||
await page.waitForURL('**/control/organizations');
|
||||
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
|
||||
|
||||
// Search input
|
||||
const searchInput = page.locator('input[placeholder*="Поиск"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('Ритейл');
|
||||
|
||||
// Filtered result should show Retail Group
|
||||
await expect(page.locator('text=Северная Ритейл Группа').first()).toBeVisible();
|
||||
|
||||
// Click 360 card button
|
||||
const inspectBtn = page.locator('button:has-text("360° Карточка")').first();
|
||||
await inspectBtn.click();
|
||||
|
||||
// Contextual Drawer opens
|
||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.locator('text=Северная Ритейл Группа').first()).toBeVisible();
|
||||
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
|
||||
|
||||
// Verify 360 tabs
|
||||
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
|
||||
await expect(drawer.locator('button:has-text("Модули")').first()).toBeVisible();
|
||||
|
||||
// Switch to Modules tab
|
||||
await drawer.locator('button:has-text("Модули")').first().click();
|
||||
await expect(drawer.locator('div:has-text("v1."), div:has-text("v2."), div:has-text("v3.")').first()).toBeVisible();
|
||||
|
||||
// Close drawer
|
||||
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('03. Scenario 2: Dangerous action requires operator authorization & mandatory reason', async ({ page }) => {
|
||||
await page.click('a[href="/control/organizations"]');
|
||||
await page.waitForURL('**/control/organizations');
|
||||
|
||||
// Open inspector for first org
|
||||
await page.locator('button:has-text("360° Карточка")').first().click();
|
||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Click trial extension button
|
||||
const grantTrialBtn = drawer.locator('button:has-text("Выдать Trial")');
|
||||
await expect(grantTrialBtn).toBeVisible();
|
||||
await grantTrialBtn.click();
|
||||
|
||||
// Confirm modal opens
|
||||
const confirmDialog = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await expect(confirmDialog.locator('text=Требуется авторизация оператора')).toBeVisible();
|
||||
|
||||
// Attempt submit without reason
|
||||
const submitBtn = confirmDialog.locator('button:has-text("Подтвердить операцию")');
|
||||
await submitBtn.click();
|
||||
await expect(confirmDialog.locator('text=Обоснование операции обязательно')).toBeVisible();
|
||||
|
||||
// Fill valid reason (>= 5 chars)
|
||||
const reasonInput = confirmDialog.locator('textarea[id="action-reason-input"]');
|
||||
await reasonInput.fill('Согласованное продление пилота по заявке #PILOT-1049');
|
||||
await submitBtn.click();
|
||||
|
||||
// Modal closes
|
||||
await expect(confirmDialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('04. Scenario 3: Filter subscriptions expiring in 7 days', async ({ page }) => {
|
||||
await page.click('a[href="/control/billing"]');
|
||||
await page.waitForURL('**/control/billing');
|
||||
|
||||
await expect(page.locator('h1:has-text("Подписки, лицензии и Entitlements")')).toBeVisible();
|
||||
|
||||
// Click "Истекает 7 дней" filter
|
||||
const filter7dBtn = page.locator('button:has-text("Истекает 7 дней")');
|
||||
await expect(filter7dBtn).toBeVisible();
|
||||
await filter7dBtn.click();
|
||||
|
||||
// Verify filter active styling and table displays matching rows
|
||||
await expect(page.locator('tbody tr')).not.toHaveCount(0);
|
||||
await expect(page.locator('text=Истекает (< 7 дн.)').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('05. Scenario 4: Monitor installations and identify outdated Android terminals', async ({ page }) => {
|
||||
await page.click('a[href="/control/installations"]');
|
||||
await page.waitForURL('**/control/installations');
|
||||
|
||||
await expect(page.locator('h1:has-text("Установки и клиентские приложения")')).toBeVisible();
|
||||
|
||||
// Click quick filter button for outdated Android
|
||||
const outdatedAndroidBtn = page.locator('button:has-text("Показать только устаревшие Android")');
|
||||
await expect(outdatedAndroidBtn).toBeVisible();
|
||||
await outdatedAndroidBtn.click();
|
||||
|
||||
// Outdated Android device should be visible in table
|
||||
await expect(page.locator('text=Android').first()).toBeVisible();
|
||||
await expect(page.locator('text=Устарела').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('06. Scenario 5: Inspect infrastructure nodes and Server Monitor outage resilience', async ({ page }) => {
|
||||
await page.click('a[href="/control/infrastructure"]');
|
||||
await page.waitForURL('**/control/infrastructure');
|
||||
|
||||
await expect(page.locator('h1:has-text("Инфраструктурные узлы")')).toBeVisible();
|
||||
|
||||
// Check circuit breaker banner simulation button
|
||||
const toggleBannerBtn = page.locator('button[title*="Server Monitor"]');
|
||||
await expect(toggleBannerBtn).toBeVisible();
|
||||
|
||||
// Verify server node metrics
|
||||
await expect(page.locator('text=CPU:').first()).toBeVisible();
|
||||
|
||||
// Click first server card to open inspector
|
||||
const firstServerCard = page.locator('div[class*="cursor-pointer"]:has-text("CPU:")').first();
|
||||
await firstServerCard.click();
|
||||
|
||||
// Context drawer opens with server details
|
||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.locator('text=Телеметрия узла').first()).toBeVisible();
|
||||
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
|
||||
});
|
||||
|
||||
test('07. Scenario 6: Create incident workflow from alert', async ({ page }) => {
|
||||
await page.click('a[href="/control/infrastructure"]');
|
||||
await page.waitForURL('**/control/infrastructure');
|
||||
|
||||
// Find a problematic server with "Создать инцидент"
|
||||
const createIncBtn = page.locator('button:has-text("Создать инцидент")').first();
|
||||
await expect(createIncBtn).toBeVisible();
|
||||
await createIncBtn.click();
|
||||
|
||||
// Confirm modal opens
|
||||
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Эскалация дежурным инженером NOC по алерту узла');
|
||||
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
|
||||
|
||||
// Navigate to Incidents desk
|
||||
await page.click('a[href="/control/incidents"]');
|
||||
await page.waitForURL('**/control/incidents');
|
||||
|
||||
// The incident alert is listed
|
||||
await expect(page.locator('text=Критический сбой узла').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('08. Scenario 7: Create targeted tenant announcement', async ({ page }) => {
|
||||
await page.click('a[href="/control/announcements"]');
|
||||
await page.waitForURL('**/control/announcements');
|
||||
|
||||
await expect(page.locator('h1:has-text("Оповещения и системные сообщения")')).toBeVisible();
|
||||
|
||||
// Open creation form
|
||||
await page.locator('button:has-text("Создать объявление")').click();
|
||||
|
||||
// Fill title
|
||||
await page.locator('input[placeholder*="Omnichannel"]').fill('Плановое ночное техобслуживание');
|
||||
|
||||
// Fill message body
|
||||
await page.locator('textarea[placeholder*="текст объявления"]').fill('12 сентября с 03:00 до 04:00 МСК будут проводиться регламентные работы.');
|
||||
|
||||
// Submit form
|
||||
await page.locator('button[type="submit"]:has-text("Опубликовать")').click();
|
||||
|
||||
// Confirmation
|
||||
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Согласованный релизный бюллетень #REL-402');
|
||||
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
|
||||
await expect(page.locator('text=Плановое ночное техобслуживание').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('09. Scenario 8: Manage module rollouts (Pause / Resume)', async ({ page }) => {
|
||||
await page.click('a[href="/control/modules"]');
|
||||
await page.waitForURL('**/control/modules');
|
||||
|
||||
await expect(page.locator('h1:has-text("Каталог модулей и Rollout конвейер")')).toBeVisible();
|
||||
|
||||
// Select module with active rollout (Омниканальные коммуникации)
|
||||
await page.locator('text=Омниканальные коммуникации').first().click();
|
||||
|
||||
// Verify active rollout badge
|
||||
const activeRolloutBadge = page.locator('text=Пилот (Pilot)').first();
|
||||
await expect(activeRolloutBadge).toBeVisible();
|
||||
|
||||
// Click Pause
|
||||
const pauseBtn = page.locator('button:has-text("Поставить на паузу")');
|
||||
if (await pauseBtn.isVisible()) {
|
||||
await pauseBtn.click();
|
||||
|
||||
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Временная приостановка раскатки до проверки логов');
|
||||
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
|
||||
|
||||
// Rollout state should now show "На паузе"
|
||||
await expect(page.locator('text=На паузе').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('10. Scenario 9: Switch operator role and observe strict RBAC enforcement', async ({ page }) => {
|
||||
const switcher = page.locator('button[aria-label="Сессия оператора"]');
|
||||
await switcher.click();
|
||||
|
||||
// Switch to security_auditor
|
||||
const auditorItem = page.locator('button[role="menuitem"]:has-text("Аудитор безопасности")');
|
||||
await expect(auditorItem).toBeVisible();
|
||||
await auditorItem.click();
|
||||
|
||||
// Topbar now shows "Аудитор безопасности"
|
||||
await expect(page.locator('text=Аудитор безопасности').first()).toBeVisible();
|
||||
|
||||
// Navigate to Modules page: triggering rollouts should be disabled for auditor
|
||||
await page.click('a[href="/control/modules"]');
|
||||
await page.waitForURL('**/control/modules');
|
||||
|
||||
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
|
||||
if (await rolloutBtn.isVisible()) {
|
||||
await expect(rolloutBtn).toBeDisabled();
|
||||
}
|
||||
|
||||
// Switch back to platform_owner
|
||||
await switcher.click();
|
||||
const ownerItem = page.locator('button[role="menuitem"]:has-text("Владелец платформы")');
|
||||
await ownerItem.click();
|
||||
await expect(page.locator('text=Владелец платформы').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('11. Scenario 10: State survives browser page reload via IndexedDB', async ({ page }) => {
|
||||
await page.click('a[href="/control/audit"]');
|
||||
await page.waitForURL('**/control/audit');
|
||||
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
||||
|
||||
// Wait for audit log rows to load from IndexedDB
|
||||
await expect(page.locator('tbody tr')).toHaveCount(3);
|
||||
const initialRowsCount = await page.locator('tbody tr').count();
|
||||
expect(initialRowsCount).toBe(3);
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Audit entries must still be present from IndexedDB
|
||||
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,8 +8,7 @@ import { BottomNav } from '../shell/BottomNav';
|
|||
import { CommandPalette } from '../shell/CommandPalette';
|
||||
import { QuickActionsModal } from '../shell/QuickActionsModal';
|
||||
import { NotificationsDrawer } from '../shell/NotificationsDrawer';
|
||||
import { Drawer } from '../../shared/components/Drawer';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Drawer, Button } from '../../shared/ui';
|
||||
|
||||
export const AppShell: React.FC = () => {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,58 @@ import {
|
|||
NotFoundPage,
|
||||
} from '../../pages';
|
||||
import { ErrorBoundary } from '../../shared/ui/ErrorBoundary';
|
||||
import { ControlCenterShell } from '../../features/control-center/components/ControlCenterShell';
|
||||
import { ControlRouteGuard } from '../../features/control-center/components/ControlRouteGuard';
|
||||
import {
|
||||
OverviewPage,
|
||||
OrganizationsPage,
|
||||
BillingPage,
|
||||
ModulesPage,
|
||||
InstallationsPage,
|
||||
InfrastructurePage,
|
||||
AnnouncementsPage,
|
||||
IncidentsPage,
|
||||
AuditPage,
|
||||
ControlForbiddenPage,
|
||||
ControlAccessGatePage,
|
||||
} from '../../features/control-center/pages';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/control/gate',
|
||||
element: <ControlAccessGatePage />,
|
||||
},
|
||||
{
|
||||
path: '/control/403',
|
||||
element: <ControlForbiddenPage />,
|
||||
},
|
||||
{
|
||||
path: '/control',
|
||||
element: (
|
||||
<ControlRouteGuard>
|
||||
<ControlCenterShell />
|
||||
</ControlRouteGuard>
|
||||
),
|
||||
errorElement: (
|
||||
<div className="p-8 bg-[#07130e] text-zinc-100 min-h-screen">
|
||||
<ErrorBoundary scope="Krona Control Center">
|
||||
<ControlForbiddenPage />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
{ path: 'organizations', element: <OrganizationsPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'modules', element: <ModulesPage /> },
|
||||
{ path: 'installations', element: <InstallationsPage /> },
|
||||
{ path: 'infrastructure', element: <InfrastructurePage /> },
|
||||
{ path: 'announcements', element: <AnnouncementsPage /> },
|
||||
{ path: 'incidents', element: <IncidentsPage /> },
|
||||
{ path: 'audit', element: <AuditPage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
|
|
@ -72,3 +122,4 @@ export const router = createBrowserRouter([
|
|||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Drawer } from '../../shared/components/Drawer';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Drawer, Button } from '../../shared/ui';
|
||||
import { Bell, AlertTriangle, AlertCircle, CheckCircle2, Info, Check, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Input } from '../../shared/components/Input';
|
||||
import { Modal, Button, Input } from '../../shared/ui';
|
||||
import {
|
||||
ShoppingBag,
|
||||
Calendar,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button, Badge } from '../../shared/ui';
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useDemoStore } from '../../mock/store';
|
|||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Button } from '../../shared/ui';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import {
|
||||
Sparkles,
|
||||
Bot,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { StatCard } from '../../shared/components/StatCard';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { StatCard, Badge, MaturityBadge } from '../../shared/ui';
|
||||
import { formatCurrency, formatNumber } from '../../shared/utils/formatters';
|
||||
import {
|
||||
XAxis,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge, MaturityBadge, Button } from '../../shared/ui';
|
||||
import { CAPABILITIES_LIST } from '../../core/capabilities/types';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import {
|
||||
ArrowDown,
|
||||
Play,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, useToast } from '../../shared/ui';
|
||||
import {
|
||||
Globe,
|
||||
Smartphone,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { INITIAL_CONVERSATIONS, ConversationItem } from '../../mock/mockData';
|
||||
import { mockApi } from '../../mock/mockApi';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, useToast } from '../../shared/ui';
|
||||
import {
|
||||
Send,
|
||||
Search,
|
||||
|
|
|
|||
228
src/features/control-center/api/index.ts
Normal file
228
src/features/control-center/api/index.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import type {
|
||||
OrganizationProjection,
|
||||
ModuleDefinition,
|
||||
Subscription,
|
||||
Installation,
|
||||
ServerNode,
|
||||
Incident,
|
||||
Announcement,
|
||||
Rollout,
|
||||
OperatorAuditEvent,
|
||||
PlatformStatus,
|
||||
RolloutStage,
|
||||
} from '../contracts';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
|
||||
export interface ControlCenterApi {
|
||||
// Queries
|
||||
getOverviewMetrics(): Promise<{
|
||||
totalOrganizations: number;
|
||||
activeOrganizations: number;
|
||||
trialOrganizations: number;
|
||||
graceOrganizations: number;
|
||||
suspendedOrganizations: number;
|
||||
totalUsers: number;
|
||||
totalBranches: number;
|
||||
totalMrrMinorUnits: number;
|
||||
expiring7dCount: number;
|
||||
expiring30dCount: number;
|
||||
installationsWeb: number;
|
||||
installationsDesktop: number;
|
||||
installationsAndroid: number;
|
||||
instancesHealthy: number;
|
||||
instancesDegraded: number;
|
||||
instancesOffline: number;
|
||||
serversHealthy: number;
|
||||
serversWarning: number;
|
||||
serversCritical: number;
|
||||
serversOffline: number;
|
||||
failedBackupsCount: number;
|
||||
overdueDrillsCount: number;
|
||||
openIncidentsCount: number;
|
||||
}>;
|
||||
|
||||
getOrganizations(search?: string, status?: PlatformStatus): Promise<OrganizationProjection[]>;
|
||||
getOrganizationById(tenantId: string): Promise<OrganizationProjection | null>;
|
||||
getModules(): Promise<ModuleDefinition[]>;
|
||||
getSubscriptions(filter?: 'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace'): Promise<Subscription[]>;
|
||||
getInstallations(kind?: Installation['kind'], status?: Installation['status']): Promise<Installation[]>;
|
||||
getServerNodes(): Promise<ServerNode[]>;
|
||||
getServerNodeById(serverId: string): Promise<ServerNode | null>;
|
||||
getIncidents(status?: Incident['status']): Promise<Incident[]>;
|
||||
getAnnouncements(): Promise<Announcement[]>;
|
||||
getRollouts(): Promise<Rollout[]>;
|
||||
getAuditLog(search?: string, actionType?: string): Promise<OperatorAuditEvent[]>;
|
||||
|
||||
// Mutations
|
||||
updatePlatformStatus(tenantId: string, status: PlatformStatus, reason: string): Promise<void>;
|
||||
grantModuleTrial(tenantId: string, moduleId: string, durationDays: number, reason: string): Promise<void>;
|
||||
grantGracePeriod(subscriptionId: string, days: number, reason: string): Promise<void>;
|
||||
renewSubscription(subscriptionId: string, period: 'month' | 'year', activeModules: string[], reason: string): Promise<void>;
|
||||
createIncident(serverId: string, summary: string, severity: Incident['severity'], reason: string): Promise<void>;
|
||||
resolveIncident(incidentId: string, reason: string): Promise<void>;
|
||||
createAnnouncement(announcement: Omit<Announcement, 'announcementId' | 'createdAt' | 'author' | 'stats'>, reason: string): Promise<void>;
|
||||
triggerRolloutStage(rolloutId: string, stage: RolloutStage, percentage: number, reason: string): Promise<void>;
|
||||
createRollout(moduleId: string, version: string, stage: RolloutStage, pilotTenantIds: string[], percentage: number, notes: string, reason: string): Promise<void>;
|
||||
requestDiagnostics(serverId: string, reason: string): Promise<void>;
|
||||
scheduleMaintenance(serverId: string, reason: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class MockControlCenterApi implements ControlCenterApi {
|
||||
async getOverviewMetrics() {
|
||||
const store = useKccStore.getState();
|
||||
const orgs = store.organizations;
|
||||
const subs = store.subscriptions;
|
||||
const insts = store.installations;
|
||||
const modulesInst = store.instances;
|
||||
const servers = store.servers;
|
||||
const incidents = store.incidents;
|
||||
|
||||
const totalMrr = orgs.reduce((acc, o) => acc + o.mrrMinorUnits, 0);
|
||||
const totalUsers = orgs.reduce((acc, o) => acc + o.userCount, 0);
|
||||
const totalBranches = orgs.reduce((acc, o) => acc + o.branchCount, 0);
|
||||
|
||||
return {
|
||||
totalOrganizations: orgs.length,
|
||||
activeOrganizations: orgs.filter((o) => o.platformStatus === 'active').length,
|
||||
trialOrganizations: orgs.filter((o) => o.platformStatus === 'trial').length,
|
||||
graceOrganizations: orgs.filter((o) => o.platformStatus === 'grace').length,
|
||||
suspendedOrganizations: orgs.filter((o) => o.platformStatus === 'suspended').length,
|
||||
totalUsers,
|
||||
totalBranches,
|
||||
totalMrrMinorUnits: totalMrr,
|
||||
expiring7dCount: subs.filter((s) => s.status === 'expiring_7d').length,
|
||||
expiring30dCount: subs.filter((s) => s.status === 'expiring_30d').length,
|
||||
installationsWeb: insts.filter((i) => i.kind === 'web').length,
|
||||
installationsDesktop: insts.filter((i) => i.kind === 'desktop').length,
|
||||
installationsAndroid: insts.filter((i) => i.kind === 'android').length,
|
||||
instancesHealthy: modulesInst.filter((i) => i.health === 'healthy').length,
|
||||
instancesDegraded: modulesInst.filter((i) => i.health === 'degraded').length,
|
||||
instancesOffline: modulesInst.filter((i) => i.health === 'offline').length,
|
||||
serversHealthy: servers.filter((s) => s.status === 'healthy').length,
|
||||
serversWarning: servers.filter((s) => s.status === 'warning').length,
|
||||
serversCritical: servers.filter((s) => s.status === 'critical').length,
|
||||
serversOffline: servers.filter((s) => s.status === 'offline').length,
|
||||
failedBackupsCount: servers.filter((s) => s.backup.state === 'failed').length,
|
||||
overdueDrillsCount: servers.filter((s) => s.restoreDrill.state === 'overdue').length,
|
||||
openIncidentsCount: incidents.filter((i) => i.status !== 'resolved').length,
|
||||
};
|
||||
}
|
||||
|
||||
async getOrganizations(search?: string, status?: PlatformStatus) {
|
||||
const orgs = useKccStore.getState().organizations;
|
||||
return orgs.filter((o) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
o.displayName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.inn.includes(search) ||
|
||||
o.domain.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = !status || o.platformStatus === status;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationById(tenantId: string) {
|
||||
return useKccStore.getState().organizations.find((o) => o.tenantId === tenantId) || null;
|
||||
}
|
||||
|
||||
async getModules() {
|
||||
return useKccStore.getState().modules;
|
||||
}
|
||||
|
||||
async getSubscriptions(filter?: 'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace') {
|
||||
const subs = useKccStore.getState().subscriptions;
|
||||
if (!filter || filter === 'all') return subs;
|
||||
return subs.filter((s) => s.status === filter);
|
||||
}
|
||||
|
||||
async getInstallations(kind?: Installation['kind'], status?: Installation['status']) {
|
||||
const insts = useKccStore.getState().installations;
|
||||
return insts.filter((i) => {
|
||||
const matchKind = !kind || i.kind === kind;
|
||||
const matchStatus = !status || i.status === status;
|
||||
return matchKind && matchStatus;
|
||||
});
|
||||
}
|
||||
|
||||
async getServerNodes() {
|
||||
return useKccStore.getState().servers;
|
||||
}
|
||||
|
||||
async getServerNodeById(serverId: string) {
|
||||
return useKccStore.getState().servers.find((s) => s.serverId === serverId) || null;
|
||||
}
|
||||
|
||||
async getIncidents(status?: Incident['status']) {
|
||||
const incidents = useKccStore.getState().incidents;
|
||||
if (!status) return incidents;
|
||||
return incidents.filter((i) => i.status === status);
|
||||
}
|
||||
|
||||
async getAnnouncements() {
|
||||
return useKccStore.getState().announcements;
|
||||
}
|
||||
|
||||
async getRollouts() {
|
||||
return useKccStore.getState().rollouts;
|
||||
}
|
||||
|
||||
async getAuditLog(search?: string, actionType?: string) {
|
||||
const log = useKccStore.getState().auditLog;
|
||||
return log.filter((item) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
item.operatorName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.reason.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.correlationId.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.action.toLowerCase().includes(search.toLowerCase());
|
||||
const matchAction = !actionType || item.action.startsWith(actionType);
|
||||
return matchSearch && matchAction;
|
||||
});
|
||||
}
|
||||
|
||||
async updatePlatformStatus(tenantId: string, status: PlatformStatus, reason: string) {
|
||||
return useKccStore.getState().updateOrganizationStatus(tenantId, status, reason);
|
||||
}
|
||||
|
||||
async grantModuleTrial(tenantId: string, moduleId: string, durationDays: number, reason: string) {
|
||||
return useKccStore.getState().grantModuleTrial(tenantId, moduleId, durationDays, reason);
|
||||
}
|
||||
|
||||
async grantGracePeriod(subscriptionId: string, days: number, reason: string) {
|
||||
return useKccStore.getState().grantGracePeriod(subscriptionId, days, reason);
|
||||
}
|
||||
|
||||
async renewSubscription(subscriptionId: string, period: 'month' | 'year', activeModules: string[], reason: string) {
|
||||
return useKccStore.getState().renewSubscription(subscriptionId, period, activeModules, reason);
|
||||
}
|
||||
|
||||
async createIncident(serverId: string, summary: string, severity: Incident['severity'], reason: string) {
|
||||
return useKccStore.getState().createIncidentFromAlert(serverId, summary, severity, reason);
|
||||
}
|
||||
|
||||
async resolveIncident(incidentId: string, reason: string) {
|
||||
return useKccStore.getState().resolveIncident(incidentId, reason);
|
||||
}
|
||||
|
||||
async createAnnouncement(announcement: Omit<Announcement, 'announcementId' | 'createdAt' | 'author' | 'stats'>, reason: string) {
|
||||
return useKccStore.getState().createAnnouncement(announcement, reason);
|
||||
}
|
||||
|
||||
async triggerRolloutStage(rolloutId: string, stage: RolloutStage, percentage: number, reason: string) {
|
||||
return useKccStore.getState().triggerRolloutStage(rolloutId, stage, percentage, reason);
|
||||
}
|
||||
|
||||
async createRollout(moduleId: string, version: string, stage: RolloutStage, pilotTenantIds: string[], percentage: number, notes: string, reason: string) {
|
||||
return useKccStore.getState().createRollout(moduleId, version, stage, pilotTenantIds, percentage, notes, reason);
|
||||
}
|
||||
|
||||
async requestDiagnostics(serverId: string, reason: string) {
|
||||
return useKccStore.getState().requestServerDiagnostics(serverId, reason);
|
||||
}
|
||||
|
||||
async scheduleMaintenance(serverId: string, reason: string) {
|
||||
return useKccStore.getState().scheduleServerMaintenance(serverId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
export const controlCenterApi: ControlCenterApi = new MockControlCenterApi();
|
||||
155
src/features/control-center/components/ConfirmOperatorAction.tsx
Normal file
155
src/features/control-center/components/ConfirmOperatorAction.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ShieldAlert, AlertTriangle, CheckCircle, X } from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
pendingModal: NonNullable<ReturnType<typeof useKccStore.getState>['pendingConfirmModal']>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModal, onClose }) => {
|
||||
const { operator, role } = useOperatorRole();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = reason.trim();
|
||||
if (trimmed.length < 5) {
|
||||
setError('Обоснование операции обязательно (минимум 5 символов).');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await pendingModal.onConfirm(trimmed);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(`Ошибка выполнения: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !isSubmitting) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl border border-amber-500/40 bg-[#0d1e17] text-zinc-100 shadow-2xl p-6">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-zinc-800 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/30">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="confirm-modal-title" className="text-lg font-bold text-zinc-100">
|
||||
{pendingModal.title}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Требуется авторизация оператора и запись в журнал аудита
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
||||
{/* Target & Action Summary */}
|
||||
<div className="rounded-lg bg-zinc-950/70 border border-zinc-800/80 p-3.5 space-y-2 text-xs">
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Целевой объект:</span>
|
||||
<span className="font-semibold text-zinc-200">{pendingModal.targetName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Операция:</span>
|
||||
<span className="font-mono text-amber-300">{pendingModal.actionName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Оператор:</span>
|
||||
<span className="text-zinc-300 font-medium">
|
||||
{operator.name} ({role})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impact Summary */}
|
||||
<div className="rounded-lg bg-amber-950/30 border border-amber-700/40 p-3.5 text-xs text-amber-200 flex gap-2.5 items-start">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-semibold text-amber-300 mb-0.5">Влияние операции (Impact Summary):</div>
|
||||
<div>{pendingModal.impactSummary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason Input */}
|
||||
<div>
|
||||
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-zinc-300 mb-1.5">
|
||||
Обоснование операции (Reason) <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="action-reason-input"
|
||||
ref={textareaRef}
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="Укажите причину выполнения действия (номер заявки, обоснование, согласование)..."
|
||||
className="w-full rounded-lg bg-zinc-950 border border-zinc-700 p-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 focus:ring-1 focus:ring-amber-500 transition-colors resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-rose-400 font-medium">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-xs font-bold rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 transition-colors flex items-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>{isSubmitting ? 'Подтверждение...' : 'Подтвердить операцию'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmOperatorActionModal: React.FC = () => {
|
||||
const pendingModal = useKccStore((state) => state.pendingConfirmModal);
|
||||
const closeModal = useKccStore((state) => state.closeConfirmModal);
|
||||
|
||||
if (!pendingModal) return null;
|
||||
|
||||
return <ConfirmOperatorActionDialog pendingModal={pendingModal} onClose={closeModal} />;
|
||||
};
|
||||
216
src/features/control-center/components/ControlCenterShell.tsx
Normal file
216
src/features/control-center/components/ControlCenterShell.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
CreditCard,
|
||||
Package,
|
||||
Smartphone,
|
||||
Server,
|
||||
BellRing,
|
||||
AlertTriangle,
|
||||
FileCheck2,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
Radio,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { OperatorSessionSwitcher } from './OperatorSessionSwitcher';
|
||||
import { InspectorDrawer } from './InspectorDrawer';
|
||||
import { ConfirmOperatorActionModal } from './ConfirmOperatorAction';
|
||||
import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
|
||||
|
||||
export const ControlCenterShell: React.FC = () => {
|
||||
const isInitialized = useKccStore((state) => state.isInitialized);
|
||||
const initialize = useKccStore((state) => state.initialize);
|
||||
const resetToDefaults = useKccStore((state) => state.resetToDefaults);
|
||||
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
|
||||
const toggleServerMonitorOutage = useKccStore((state) => state.toggleServerMonitorOutage);
|
||||
|
||||
const [utcTime, setUtcTime] = useState('');
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [showSavedFeedback, setShowSavedFeedback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
initialize();
|
||||
}
|
||||
}, [isInitialized, initialize]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setUtcTime(now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC');
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleReset = async () => {
|
||||
if (confirm('Сбросить состояние демонстрационной базы данных к исходному состоянию?')) {
|
||||
setIsResetting(true);
|
||||
await resetToDefaults();
|
||||
setIsResetting(false);
|
||||
setShowSavedFeedback(true);
|
||||
setTimeout(() => setShowSavedFeedback(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||
{ to: '/control/billing', label: 'Подписки и биллинг', icon: CreditCard },
|
||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||
{ to: '/control/installations', label: 'Установки и клиенты', icon: Smartphone },
|
||||
{ to: '/control/infrastructure', label: 'Инфраструктура', icon: Server },
|
||||
{ to: '/control/announcements', label: 'Сообщения', icon: BellRing },
|
||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle },
|
||||
{ to: '/control/audit', label: 'Неизменяемый аудит', icon: FileCheck2 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen max-w-full overflow-x-hidden bg-[#07130e] text-zinc-100 flex flex-col font-sans selection:bg-amber-500 selection:text-zinc-950">
|
||||
{/* Top Console Bar */}
|
||||
<header className="h-14 border-b border-zinc-800 bg-[#0a1b14] px-2.5 sm:px-4 flex items-center justify-between z-30 shrink-0 max-w-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
{/* Logo & Title */}
|
||||
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
|
||||
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-emerald-950 border border-amber-500/40 flex items-center justify-center text-amber-400 font-extrabold shadow-sm shrink-0">
|
||||
K
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-black tracking-widest text-amber-400 uppercase flex items-center gap-1.5">
|
||||
<span className="shrink-0">Krona Control Center</span>
|
||||
<span className="text-[9px] px-1.5 py-0.2 rounded-xs bg-emerald-900/80 text-emerald-300 font-mono hidden sm:inline">
|
||||
DEMO v1.0
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400 font-mono tracking-wider hidden sm:block">
|
||||
Кабинет владельца платформы
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500 font-mono pl-2 sm:pl-4 border-l border-zinc-800 shrink-0">
|
||||
<span className="hidden xl:inline">{utcTime}</span>
|
||||
<span className="hidden xl:inline">•</span>
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse shrink-0" />
|
||||
<span className="text-[10px] sm:text-xs">IndexedDB Synced</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Header Actions */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-3 shrink-0">
|
||||
{/* Server Monitor Simulation Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleServerMonitorOutage}
|
||||
className={`flex items-center gap-1.5 px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
|
||||
serverMonitorOutage
|
||||
? 'bg-rose-950 text-rose-300 border-rose-700/60'
|
||||
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
title="Переключить симуляцию недоступности Server Monitor"
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${serverMonitorOutage ? 'text-rose-400 animate-pulse' : 'text-zinc-500'} shrink-0`} />
|
||||
<span className="hidden md:inline">{serverMonitorOutage ? 'SM Outage Sim (Активен)' : 'Simulate SM Outage'}</span>
|
||||
</button>
|
||||
|
||||
{/* Reset Demo DB Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="hidden sm:flex items-center gap-1.5 px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-800 transition-colors disabled:opacity-50 shrink-0"
|
||||
title="Сбросить демонстрационные данные к эталону"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${isResetting ? 'animate-spin' : ''} shrink-0`} />
|
||||
<span className="hidden md:inline">Сброс Demo</span>
|
||||
</button>
|
||||
|
||||
{/* Operator RBAC Switcher */}
|
||||
<OperatorSessionSwitcher />
|
||||
|
||||
{/* Return to Client App Link */}
|
||||
<NavLink
|
||||
to="/"
|
||||
className="hidden xl:flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors pl-2"
|
||||
title="Перейти в клиентское приложение платформы"
|
||||
>
|
||||
<span>В приложение</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</NavLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Console Layout */}
|
||||
<div className="flex-1 flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||
{/* Compact Operator Sidebar */}
|
||||
<aside
|
||||
className="w-full md:w-56 border-b md:border-b-0 md:border-r border-zinc-800 bg-[#091712] flex flex-row md:flex-col shrink-0 overflow-x-auto md:overflow-y-auto"
|
||||
aria-label="Навигация Control Center"
|
||||
>
|
||||
<div className="p-2 md:p-3 flex flex-row md:flex-col gap-1 shrink-0">
|
||||
<div className="hidden md:block px-3 py-1.5 text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Управление
|
||||
</div>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? 'bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold'
|
||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/60'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Footer Info */}
|
||||
<div className="hidden md:block mt-auto p-3 border-t border-zinc-800/80 text-[11px] text-zinc-500 space-y-1 bg-zinc-950/40">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Security Gate:</span>
|
||||
<span className="text-emerald-400 font-semibold">Active</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Node Identity:</span>
|
||||
<span className="text-zinc-400 font-mono">178.212.13.102</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content Workspace */}
|
||||
<main className="flex-1 flex flex-col overflow-y-auto bg-[#07130e] p-3 sm:p-4 md:p-6 min-w-0 max-w-full">
|
||||
<ServerMonitorOutageBanner />
|
||||
{showSavedFeedback && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-emerald-950/80 border border-emerald-700/60 text-emerald-200 text-xs flex items-center gap-2 animate-in fade-in">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Демонстрационные данные успешно сброшены и сохранены в IndexedDB.</span>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Contextual Inspector Drawer */}
|
||||
<InspectorDrawer />
|
||||
|
||||
{/* Modal Dialog for Dangerous Operations */}
|
||||
<ConfirmOperatorActionModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
src/features/control-center/components/ControlRouteGuard.tsx
Normal file
26
src/features/control-center/components/ControlRouteGuard.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { ControlForbiddenPage } from '../pages/ControlForbiddenPage';
|
||||
|
||||
const ALLOWED_OPERATOR_ROLES = [
|
||||
'platform_owner',
|
||||
'platform_admin',
|
||||
'support_operator',
|
||||
'billing_manager',
|
||||
'security_auditor',
|
||||
'release_manager',
|
||||
];
|
||||
|
||||
interface ControlRouteGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ControlRouteGuard: React.FC<ControlRouteGuardProps> = ({ children }) => {
|
||||
const activeRole = useKccStore((state) => state.activeRole);
|
||||
|
||||
if (!ALLOWED_OPERATOR_ROLES.includes(activeRole)) {
|
||||
return <ControlForbiddenPage />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
64
src/features/control-center/components/HeartbeatTimeline.tsx
Normal file
64
src/features/control-center/components/HeartbeatTimeline.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { HealthState } from '../contracts';
|
||||
|
||||
interface HeartbeatTimelineProps {
|
||||
lastSeenAt?: string;
|
||||
status: HealthState | 'healthy' | 'warning' | 'critical' | 'offline';
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const HeartbeatTimeline: React.FC<HeartbeatTimelineProps> = ({
|
||||
status,
|
||||
count = 24,
|
||||
className = '',
|
||||
}) => {
|
||||
// Deterministically generate a realistic historical series leading up to current status
|
||||
const bars = Array.from({ length: count }, (_, idx) => {
|
||||
// Current bar is at the end
|
||||
if (idx === count - 1) {
|
||||
return status;
|
||||
}
|
||||
if (status === 'offline') {
|
||||
// Last 3 are offline, before were warning
|
||||
return idx >= count - 4 ? 'offline' : idx >= count - 8 ? 'warning' : 'healthy';
|
||||
}
|
||||
if (status === 'critical') {
|
||||
return idx >= count - 3 ? 'critical' : idx >= count - 6 ? 'warning' : 'healthy';
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return idx >= count - 2 ? 'warning' : 'healthy';
|
||||
}
|
||||
// Mostly healthy with occasional degraded
|
||||
if (idx === 7 || idx === 18) return 'warning';
|
||||
return 'healthy';
|
||||
});
|
||||
|
||||
const getColorClass = (state: string) => {
|
||||
switch (state) {
|
||||
case 'healthy':
|
||||
return 'bg-emerald-500 hover:bg-emerald-400';
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'bg-amber-400 hover:bg-amber-300';
|
||||
case 'critical':
|
||||
return 'bg-rose-500 hover:bg-rose-400';
|
||||
case 'offline':
|
||||
return 'bg-zinc-700 hover:bg-zinc-600';
|
||||
default:
|
||||
return 'bg-zinc-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`} title={`Heartbeat status: ${status}`}>
|
||||
{bars.map((barStatus, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-4 w-1.5 rounded-xs transition-all duration-150 ${getColorClass(barStatus)}`}
|
||||
title={`Интервал -${count - 1 - index}h: ${barStatus}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
639
src/features/control-center/components/InspectorDrawer.tsx
Normal file
639
src/features/control-center/components/InspectorDrawer.tsx
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
Smartphone,
|
||||
ShieldAlert,
|
||||
Clock,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { MoneyDisplay } from './MoneyDisplay';
|
||||
import { HeartbeatTimeline } from './HeartbeatTimeline';
|
||||
import { PlatformStatus } from '../contracts';
|
||||
|
||||
export const InspectorDrawer: React.FC = () => {
|
||||
const inspectorTarget = useKccStore((state) => state.inspectorTarget);
|
||||
const closeInspector = useKccStore((state) => state.closeInspector);
|
||||
|
||||
const { permissions } = useOperatorRole();
|
||||
const organizations = useKccStore((state) => state.organizations);
|
||||
const servers = useKccStore((state) => state.servers);
|
||||
const incidents = useKccStore((state) => state.incidents);
|
||||
const modules = useKccStore((state) => state.modules);
|
||||
const installations = useKccStore((state) => state.installations);
|
||||
const subscriptions = useKccStore((state) => state.subscriptions);
|
||||
const instances = useKccStore((state) => state.instances);
|
||||
const auditLog = useKccStore((state) => state.auditLog);
|
||||
|
||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||
const updateOrganizationStatus = useKccStore((state) => state.updateOrganizationStatus);
|
||||
const grantModuleTrial = useKccStore((state) => state.grantModuleTrial);
|
||||
const resolveIncident = useKccStore((state) => state.resolveIncident);
|
||||
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
|
||||
const scheduleServerMaintenance = useKccStore((state) => state.scheduleServerMaintenance);
|
||||
|
||||
const [orgActiveTab, setOrgActiveTab] = useState<
|
||||
'overview' | 'users' | 'modules' | 'installations' | 'billing' | 'incidents' | 'audit'
|
||||
>('overview');
|
||||
const [selectedTrialModule, setSelectedTrialModule] = useState('krona-omnichannel');
|
||||
const [trialDurationDays, setTrialDurationDays] = useState(14);
|
||||
|
||||
if (!inspectorTarget) return null;
|
||||
|
||||
const renderContent = () => {
|
||||
switch (inspectorTarget.type) {
|
||||
case 'organization': {
|
||||
const org = organizations.find((o) => o.tenantId === inspectorTarget.id);
|
||||
if (!org) return <div className="p-6 text-zinc-400">Организация не найдена</div>;
|
||||
|
||||
const sub = subscriptions.find((s) => s.tenantId === org.tenantId);
|
||||
const orgInstances = instances.filter((i) => i.tenantId === org.tenantId);
|
||||
const orgInstalls = installations.filter((i) => i.tenantId === org.tenantId);
|
||||
const orgIncidents = incidents.filter((i) => i.affectedTenantIds.includes(org.tenantId));
|
||||
const orgAudits = auditLog.filter((a) => a.target.tenantId === org.tenantId || a.target.id === org.tenantId);
|
||||
|
||||
const handleStatusChange = (newStatus: PlatformStatus) => {
|
||||
openConfirmModal({
|
||||
title: `Смена статуса организации: ${org.displayName}`,
|
||||
actionName: `organization.status_changed -> ${newStatus}`,
|
||||
impactSummary: `Доступ всех ${org.userCount} пользователей к платформе будет переведен в состояние «${newStatus}».`,
|
||||
targetType: 'organization',
|
||||
targetId: org.tenantId,
|
||||
targetName: org.displayName,
|
||||
onConfirm: async (reason) => {
|
||||
await updateOrganizationStatus(org.tenantId, newStatus, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGrantTrial = () => {
|
||||
const mod = modules.find((m) => m.moduleId === selectedTrialModule);
|
||||
openConfirmModal({
|
||||
title: `Выдача Trial: ${mod?.name || selectedTrialModule}`,
|
||||
actionName: `entitlement.grant_trial (${selectedTrialModule})`,
|
||||
impactSummary: `Организации будет предоставлен временный доступ к модулю ${mod?.name} на ${trialDurationDays} дней без списания средств.`,
|
||||
targetType: 'organization',
|
||||
targetId: org.tenantId,
|
||||
targetName: org.displayName,
|
||||
onConfirm: async (reason) => {
|
||||
await grantModuleTrial(org.tenantId, selectedTrialModule, trialDurationDays, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<StatusBadge status={org.platformStatus} />
|
||||
<span className="text-xs text-zinc-400 font-mono">ИНН: {org.inn}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{org.displayName}</h2>
|
||||
<p className="text-xs text-zinc-400 font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
|
||||
</div>
|
||||
|
||||
{/* Operator Actions Bar */}
|
||||
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-3">
|
||||
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
||||
<span>Действия оператора платформы</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{org.platformStatus !== 'active' && (
|
||||
<button
|
||||
disabled={!permissions.canManagePlatformStatus}
|
||||
onClick={() => handleStatusChange('active')}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-emerald-950/70 text-emerald-300 border border-emerald-700/50 hover:bg-emerald-900/50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Активировать (Active)
|
||||
</button>
|
||||
)}
|
||||
{org.platformStatus !== 'grace' && (
|
||||
<button
|
||||
disabled={!permissions.canGrantTrialGrace}
|
||||
onClick={() => handleStatusChange('grace')}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-orange-950/70 text-orange-300 border border-orange-700/50 hover:bg-orange-900/50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Назначить Grace
|
||||
</button>
|
||||
)}
|
||||
{org.platformStatus !== 'suspended' && (
|
||||
<button
|
||||
disabled={!permissions.canManagePlatformStatus}
|
||||
onClick={() => handleStatusChange('suspended')}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-rose-950/70 text-rose-300 border border-rose-700/50 hover:bg-rose-900/50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Заблокировать (Suspend)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trial grant inline form */}
|
||||
{permissions.canGrantTrialGrace && (
|
||||
<div className="pt-2 border-t border-zinc-800/80 space-y-2 text-xs">
|
||||
<div className="font-semibold text-zinc-300">Выдать Trial на модуль:</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={selectedTrialModule}
|
||||
onChange={(e) => setSelectedTrialModule(e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
|
||||
>
|
||||
{modules.map((m) => (
|
||||
<option key={m.moduleId} value={m.moduleId}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={trialDurationDays}
|
||||
onChange={(e) => setTrialDurationDays(Number(e.target.value))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
|
||||
>
|
||||
<option value={7}>7 дней</option>
|
||||
<option value={14}>14 дней</option>
|
||||
<option value={30}>30 дней</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGrantTrial}
|
||||
className="px-3 py-1 rounded-md bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shrink-0"
|
||||
>
|
||||
Выдать Trial
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-zinc-800 overflow-x-auto pb-1 text-xs">
|
||||
{[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'users', label: `Пользователи (${org.userCount})` },
|
||||
{ id: 'modules', label: `Модули (${orgInstances.length})` },
|
||||
{ id: 'installations', label: `Установки (${orgInstalls.length})` },
|
||||
{ id: 'billing', label: 'Платежи' },
|
||||
{ id: 'incidents', label: `Инциденты (${orgIncidents.length})` },
|
||||
{ id: 'audit', label: 'Аудит' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setOrgActiveTab(tab.id as typeof orgActiveTab)}
|
||||
className={`px-3 py-1.5 rounded-t-md font-medium whitespace-nowrap transition-colors ${
|
||||
orgActiveTab === tab.id
|
||||
? 'bg-zinc-800/80 text-amber-300 border-b-2 border-amber-400'
|
||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Contents */}
|
||||
{orgActiveTab === 'overview' && (
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">Тарифный план</div>
|
||||
<div className="font-semibold text-zinc-200 text-sm">{org.tariffPlan}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">MRR доход</div>
|
||||
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-400" />
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">Филиалов / точек</div>
|
||||
<div className="font-semibold text-zinc-200 text-sm">{org.branchCount}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">Задолженность</div>
|
||||
<MoneyDisplay
|
||||
minorUnits={org.outstandingDebtMinorUnits}
|
||||
className={`text-sm font-bold ${org.outstandingDebtMinorUnits > 0 ? 'text-rose-400' : 'text-emerald-400'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-zinc-950/60 border border-zinc-800 p-3 space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Контакты организации:</div>
|
||||
<div className="text-zinc-400">Email: <span className="text-zinc-200">{org.contactEmail}</span></div>
|
||||
<div className="text-zinc-400">Телефон: <span className="text-zinc-200">{org.contactPhone}</span></div>
|
||||
<div className="text-zinc-400">Последняя активность: <span className="text-zinc-200">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
|
||||
<div className="text-zinc-400">Окончание подписки: <span className="text-zinc-200">{new Date(org.subscriptionEndAt).toLocaleDateString('ru-RU')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'users' && (
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-semibold text-zinc-200">Пользователи-агрегаты</span>
|
||||
<span className="text-amber-400 font-bold">{org.userCount} учетных записей</span>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-[11px] leading-relaxed">
|
||||
В соответствии с контрактом безопасности Control Center отображает только деперсонализированные
|
||||
агрегаты тенанта. Персональные данные сотрудников защищены контуром организации.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-zinc-300">
|
||||
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<div className="text-zinc-400">Администраторы</div>
|
||||
<div className="font-bold text-sm text-zinc-100">2</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<div className="text-zinc-400">Менеджеры / операторы</div>
|
||||
<div className="font-bold text-sm text-zinc-100">{Math.max(0, org.userCount - 6)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'modules' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgInstances.map((inst) => (
|
||||
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
||||
v{inst.currentVersion} • {inst.serverName}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={inst.health} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'installations' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgInstalls.map((ins) => (
|
||||
<div key={ins.installationId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200 flex items-center gap-1.5">
|
||||
<Smartphone className="w-3.5 h-3.5 text-zinc-400" />
|
||||
<span>{ins.deviceName}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
||||
{ins.kind.toUpperCase()} • v{ins.appVersion} • {ins.osVersion}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={ins.status} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
{orgInstalls.length === 0 && <div className="text-zinc-500 py-4 text-center">Нет активных установок</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'billing' && (
|
||||
<div className="space-y-3 text-xs">
|
||||
{sub ? (
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-200">Тарифная подписка</span>
|
||||
<StatusBadge status={sub.status} size="sm" />
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Период:</span>
|
||||
<span className="text-zinc-200">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Сумма тарифа:</span>
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Действует до:</span>
|
||||
<span className="text-zinc-200">{new Date(sub.validUntil).toLocaleDateString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-500 py-4 text-center">Данные подписки отсутствуют</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'incidents' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgIncidents.map((inc) => (
|
||||
<div key={inc.incidentId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-zinc-200 font-mono">{inc.incidentNumber}</span>
|
||||
<StatusBadge status={inc.status} size="sm" />
|
||||
</div>
|
||||
<p className="text-zinc-300">{inc.summary}</p>
|
||||
</div>
|
||||
))}
|
||||
{orgIncidents.length === 0 && <div className="text-zinc-500 py-4 text-center">Инцидентов нет</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'audit' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgAudits.map((a) => (
|
||||
<div key={a.eventId} className="p-2.5 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1">
|
||||
<div className="flex justify-between text-zinc-400 font-mono text-[10px]">
|
||||
<span>{a.action}</span>
|
||||
<span>{new Date(a.occurredAt).toLocaleTimeString('ru-RU')}</span>
|
||||
</div>
|
||||
<p className="text-zinc-200 text-[11px]">{a.reason}</p>
|
||||
<div className="text-[10px] text-amber-400/80">{a.operatorName} ({a.operatorRole})</div>
|
||||
</div>
|
||||
))}
|
||||
{orgAudits.length === 0 && <div className="text-zinc-500 py-4 text-center">Записей аудита нет</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'server': {
|
||||
const srv = servers.find((s) => s.serverId === inspectorTarget.id);
|
||||
if (!srv) return <div className="p-6 text-zinc-400">Сервер не найден</div>;
|
||||
|
||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||
|
||||
const handleDiagnostics = () => {
|
||||
openConfirmModal({
|
||||
title: `Запрос диагностики: ${srv.name}`,
|
||||
actionName: 'infrastructure.request_diagnostics',
|
||||
impactSummary: `На узел ${srv.ipAddress} будет отправлен запрос сбора расширенного дампа метрик, журнала dmesg и состояния процессов без раскрытия секретов.`,
|
||||
targetType: 'infrastructure',
|
||||
targetId: srv.serverId,
|
||||
targetName: srv.name,
|
||||
onConfirm: async (reason) => {
|
||||
await requestServerDiagnostics(srv.serverId, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMaintenance = () => {
|
||||
openConfirmModal({
|
||||
title: `Запланировать ТО: ${srv.name}`,
|
||||
actionName: 'infrastructure.schedule_maintenance',
|
||||
impactSummary: `Узел ${srv.name} будет помечен как обслуживаемый. Маршрутизация трафика будет плавно перенаправлена на резервные ноды.`,
|
||||
targetType: 'infrastructure',
|
||||
targetId: srv.serverId,
|
||||
targetName: srv.name,
|
||||
onConfirm: async (reason) => {
|
||||
await scheduleServerMaintenance(srv.serverId, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-xs">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<StatusBadge status={srv.status} />
|
||||
<span className="text-xs text-zinc-400 font-mono">{srv.hostingProvider} • {srv.region}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{srv.name}</h2>
|
||||
<p className="text-xs text-zinc-400 font-mono">{srv.ipAddress} • {srv.hostname}</p>
|
||||
</div>
|
||||
|
||||
{/* Operator Actions */}
|
||||
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-2.5">
|
||||
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
||||
<span>Операции с инфраструктурным узлом</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={!permissions.canRequestDiagnostics}
|
||||
onClick={handleDiagnostics}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-800 text-zinc-200 hover:bg-zinc-700 disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5 text-sky-400" />
|
||||
<span>Запросить диагностику</span>
|
||||
</button>
|
||||
<button
|
||||
disabled={!permissions.canScheduleMaintenance}
|
||||
onClick={handleMaintenance}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-950/60 text-amber-300 border border-amber-700/50 hover:bg-amber-900/40 disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Запланировать ТО</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Telemetry Metrics */}
|
||||
<div className="space-y-3">
|
||||
<div className="font-semibold text-zinc-300 flex items-center justify-between">
|
||||
<span>Телеметрия узла (Server Monitor Agent):</span>
|
||||
<span className="text-[11px] text-zinc-400 font-mono">Agent v{srv.agentVersion} ({srv.agentStatus})</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="flex justify-between text-zinc-400 mb-1">
|
||||
<span>CPU Нагрузка:</span>
|
||||
<span className="font-bold text-zinc-100">{srv.cpuLoadPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${srv.cpuLoadPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="flex justify-between text-zinc-400 mb-1">
|
||||
<span>RAM Память:</span>
|
||||
<span className="font-bold text-zinc-100">
|
||||
{Math.round(srv.ramUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.ramTotalBytes / (1024 * 1024 * 1024))} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="flex justify-between text-zinc-400 mb-1">
|
||||
<span>Диск:</span>
|
||||
<span className="font-bold text-zinc-100">
|
||||
{Math.round(srv.diskUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.diskTotalBytes / (1024 * 1024 * 1024))} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
|
||||
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="text-zinc-400 mb-1">Сеть (Rx / Tx):</div>
|
||||
<div className="font-mono text-zinc-200">
|
||||
{(srv.networkRxBytesPerSec / 1024 / 1024).toFixed(1)} / {(srv.networkTxBytesPerSec / 1024 / 1024).toFixed(1)} MB/s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat timeline */}
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-300">Пульс узла (Heartbeat 24h):</span>
|
||||
<span className="text-[11px] text-zinc-400 font-mono">RTT: {srv.latencyMs}ms</span>
|
||||
</div>
|
||||
<HeartbeatTimeline status={srv.status} count={24} />
|
||||
</div>
|
||||
|
||||
{/* Backup & Restore Drill */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
||||
<div className="font-semibold text-zinc-300 flex items-center justify-between">
|
||||
<span>Бэкап:</span>
|
||||
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state.toUpperCase()} />
|
||||
</div>
|
||||
<div className="text-zinc-400 text-[11px]">
|
||||
{srv.backup.lastSuccessfulAt ? new Date(srv.backup.lastSuccessfulAt).toLocaleString('ru-RU') : 'Нет успешных копий'}
|
||||
</div>
|
||||
<div className="text-zinc-500 text-[10px]">{srv.backup.targetProvider}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
||||
<div className="font-semibold text-zinc-300 flex items-center justify-between">
|
||||
<span>Restore Drill:</span>
|
||||
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state.toUpperCase()} />
|
||||
</div>
|
||||
<div className="text-zinc-400 text-[11px]">
|
||||
{srv.restoreDrill.verifiedAt ? new Date(srv.restoreDrill.verifiedAt).toLocaleDateString('ru-RU') : 'Никогда / Устарел'}
|
||||
</div>
|
||||
<div className="text-zinc-500 text-[10px]">
|
||||
{srv.restoreDrill.drillDurationSeconds ? `Время восстановления: ${Math.round(srv.restoreDrill.drillDurationSeconds / 60)} мин.` : 'Тест не проводился'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module instances on server */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Модули на узле ({srvInstances.length}):</div>
|
||||
{srvInstances.map((inst) => (
|
||||
<div key={inst.moduleInstanceId} className="p-2.5 rounded-lg bg-zinc-950 border border-zinc-800 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
|
||||
<div className="text-zinc-400 text-[11px]">{inst.tenantName}</div>
|
||||
</div>
|
||||
<StatusBadge status={inst.health} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'incident': {
|
||||
const inc = incidents.find((i) => i.incidentId === inspectorTarget.id);
|
||||
if (!inc) return <div className="p-6 text-zinc-400">Инцидент не найден</div>;
|
||||
|
||||
const handleResolve = () => {
|
||||
openConfirmModal({
|
||||
title: `Разрешение инцидента ${inc.incidentNumber}`,
|
||||
actionName: 'incident.resolve',
|
||||
impactSummary: `Инцидент будет переведен в статус resolved, дежурная смена будет оповещена об устранении причин.`,
|
||||
targetType: 'incident',
|
||||
targetId: inc.incidentId,
|
||||
targetName: inc.incidentNumber,
|
||||
onConfirm: async (reason) => {
|
||||
await resolveIncident(inc.incidentId, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-xs">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<StatusBadge status={inc.status} />
|
||||
<span className="font-bold text-amber-400 uppercase tracking-wider">{inc.severity} Severity</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{inc.incidentNumber}</h2>
|
||||
<p className="text-sm text-zinc-200 mt-1 font-medium">{inc.summary}</p>
|
||||
</div>
|
||||
|
||||
{inc.status !== 'resolved' && (
|
||||
<div className="p-3.5 rounded-xl bg-zinc-950 border border-zinc-800 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200">Действие оператора:</div>
|
||||
<div className="text-zinc-400 text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={!permissions.canManageIncidents}
|
||||
onClick={handleResolve}
|
||||
className="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-bold text-xs transition-colors disabled:opacity-40"
|
||||
>
|
||||
Разрешить инцидент
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-zinc-950 p-3.5 border border-zinc-800 space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Описание инцидента:</div>
|
||||
<p className="text-zinc-300 leading-relaxed">{inc.description}</p>
|
||||
<div className="text-zinc-400 pt-2 border-t border-zinc-800">
|
||||
Ответственный: <span className="text-zinc-200">{inc.ownerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Хронология расследования (Timeline):</div>
|
||||
<div className="space-y-2 relative border-l border-zinc-800 ml-2 pl-3">
|
||||
{inc.timeline.map((step, idx) => (
|
||||
<div key={idx} className="relative space-y-1">
|
||||
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-400" />
|
||||
<div className="flex justify-between text-zinc-400 text-[10px]">
|
||||
<span className="font-bold text-zinc-300">{step.author}</span>
|
||||
<span>{new Date(step.timestamp).toLocaleTimeString('ru-RU')}</span>
|
||||
</div>
|
||||
<p className="text-zinc-300">{step.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <div className="p-6 text-zinc-400">Детальная информация для данного типа пока формируется</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 z-40 w-full max-w-xl bg-[#0d1e17] border-l border-zinc-800 shadow-2xl flex flex-col text-zinc-100 animate-in slide-in-from-right duration-200"
|
||||
role="complementary"
|
||||
aria-label="Контекстный инспектор"
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between bg-zinc-950/60">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
<Activity className="w-4 h-4 text-amber-400" />
|
||||
<span>Контекстный инспектор</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeInspector}
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Закрыть инспектор"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
src/features/control-center/components/MoneyDisplay.tsx
Normal file
29
src/features/control-center/components/MoneyDisplay.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
|
||||
interface MoneyDisplayProps {
|
||||
minorUnits: number;
|
||||
currency?: string;
|
||||
className?: string;
|
||||
showZeroAsFree?: boolean;
|
||||
}
|
||||
|
||||
export const MoneyDisplay: React.FC<MoneyDisplayProps> = ({
|
||||
minorUnits,
|
||||
currency = 'RUB',
|
||||
className = '',
|
||||
showZeroAsFree = false,
|
||||
}) => {
|
||||
if (showZeroAsFree && minorUnits === 0) {
|
||||
return <span className={`text-emerald-400 font-medium ${className}`}>0 ₽ (Бесплатно / Trial)</span>;
|
||||
}
|
||||
|
||||
const majorUnits = minorUnits / 100;
|
||||
const formatted = new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(majorUnits);
|
||||
|
||||
return <span className={`tabular-nums font-medium ${className}`}>{formatted}</span>;
|
||||
};
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ShieldCheck, ChevronDown, UserCircle } from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { OPERATOR_ROLE_DEFINITIONS, OperatorRole } from '../contracts';
|
||||
|
||||
interface OperatorSessionSwitcherProps {
|
||||
onRoleChange?: (role: OperatorRole) => void;
|
||||
}
|
||||
|
||||
export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = ({ onRoleChange }) => {
|
||||
const { role, operator } = useOperatorRole();
|
||||
const setActiveRole = useKccStore((state) => state.setActiveRole);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const rolesList: OperatorRole[] = [
|
||||
'platform_owner',
|
||||
'platform_admin',
|
||||
'support_operator',
|
||||
'billing_manager',
|
||||
'security_auditor',
|
||||
'release_manager',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 transition-colors text-xs font-medium focus:outline-hidden focus:ring-1 focus:ring-amber-400"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-label="Сессия оператора"
|
||||
title="Сменить роль оператора платформы (RBAC демо)"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
<div className="text-left leading-tight">
|
||||
<div className="font-bold text-amber-200 hidden sm:block">{operator.name}</div>
|
||||
<div className="text-[10px] text-amber-400/80 uppercase tracking-wider font-mono">
|
||||
{OPERATOR_ROLE_DEFINITIONS[role].label}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-amber-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-80 rounded-xl border border-zinc-700/80 bg-[#0d1e17] shadow-2xl z-50 p-2 text-zinc-200 animate-in fade-in slide-in-from-top-2 duration-150"
|
||||
role="menu"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400">
|
||||
<span className="font-semibold text-zinc-200">Операторская сессия (RBAC Demo)</span>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5">
|
||||
Выберите роль для проверки разграничения прав доступа:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-1 space-y-1">
|
||||
{rolesList.map((r) => {
|
||||
const def = OPERATOR_ROLE_DEFINITIONS[r];
|
||||
const isSelected = r === role;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveRole(r);
|
||||
onRoleChange?.(r);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-xs transition-colors flex items-start gap-2.5 ${
|
||||
isSelected
|
||||
? 'bg-amber-500/20 text-amber-200 border border-amber-500/40 font-medium'
|
||||
: 'hover:bg-zinc-800/80 text-zinc-300'
|
||||
}`}
|
||||
role="menuitem"
|
||||
>
|
||||
<UserCircle
|
||||
className={`w-4 h-4 mt-0.5 shrink-0 ${isSelected ? 'text-amber-400' : 'text-zinc-400'}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-bold flex items-center gap-1.5">
|
||||
<span>{def.label}</span>
|
||||
{isSelected && (
|
||||
<span className="text-[10px] px-1.5 py-0.2 rounded-xs bg-amber-500 text-zinc-950 font-extrabold uppercase">
|
||||
Активна
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 mt-0.5 leading-snug">{def.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import { AlertOctagon, RefreshCw } from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
|
||||
export const ServerMonitorOutageBanner: React.FC = () => {
|
||||
const outage = useKccStore((state) => state.serverMonitorOutage);
|
||||
const toggleOutage = useKccStore((state) => state.toggleServerMonitorOutage);
|
||||
|
||||
if (!outage) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 rounded-xl border border-red-500/50 bg-red-950/40 p-4 text-red-200 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-lg shadow-red-950/20 animate-in fade-in slide-in-from-top-1"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-red-500/20 text-red-400 border border-red-500/40 shrink-0 mt-0.5">
|
||||
<AlertOctagon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm text-red-300">
|
||||
Внимание: Сервис Server Monitor временно недоступен (Circuit Breaker Tripped)
|
||||
</div>
|
||||
<div className="text-xs text-red-300/80 mt-0.5">
|
||||
Прямой mTLS канал с агентами узлов не отвечает. Разделы организаций, подписок и биллинга продолжают штатную
|
||||
работу. Показаны последние закешированные данные телеметрии.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOutage}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg border border-red-500/40 bg-red-500/20 hover:bg-red-500/30 text-red-200 text-xs font-semibold flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>Восстановить связь (Demo)</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
227
src/features/control-center/components/StatusBadge.tsx
Normal file
227
src/features/control-center/components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
Smartphone,
|
||||
Server,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type StatusBadgeVariant =
|
||||
| 'active'
|
||||
| 'trial'
|
||||
| 'grace'
|
||||
| 'suspended'
|
||||
| 'closed'
|
||||
| 'normal'
|
||||
| 'overdue'
|
||||
| 'expiring_7d'
|
||||
| 'expiring_30d'
|
||||
| 'healthy'
|
||||
| 'degraded'
|
||||
| 'warning'
|
||||
| 'critical'
|
||||
| 'offline'
|
||||
| 'unknown'
|
||||
| 'supported'
|
||||
| 'outdated'
|
||||
| 'blocked'
|
||||
| 'revoked'
|
||||
| 'pilot'
|
||||
| 'draft'
|
||||
| 'percentage'
|
||||
| 'general_availability'
|
||||
| 'paused'
|
||||
| 'rolled_back'
|
||||
| 'open'
|
||||
| 'mitigating'
|
||||
| 'resolved';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: StatusBadgeVariant;
|
||||
customLabel?: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
interface BadgeConfig {
|
||||
label: string;
|
||||
className: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, size = 'md' }) => {
|
||||
const isSmall = size === 'sm';
|
||||
const iconSize = isSmall ? 'w-3 h-3' : 'w-3.5 h-3.5';
|
||||
|
||||
const configs: Record<StatusBadgeVariant, BadgeConfig> = {
|
||||
// Platform & tenant
|
||||
active: {
|
||||
label: 'Активна',
|
||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
trial: {
|
||||
label: 'Trial (Пробный)',
|
||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
grace: {
|
||||
label: 'Grace Period',
|
||||
className: 'bg-orange-950/80 text-orange-400 border-orange-700/50',
|
||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
suspended: {
|
||||
label: 'Приостановлена',
|
||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
closed: {
|
||||
label: 'Закрыта',
|
||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700/50',
|
||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
normal: {
|
||||
label: 'Оплачена',
|
||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
overdue: {
|
||||
label: 'Просрочена',
|
||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
expiring_7d: {
|
||||
label: 'Истекает (< 7 дн.)',
|
||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50 font-semibold',
|
||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
expiring_30d: {
|
||||
label: 'Истекает (< 30 дн.)',
|
||||
className: 'bg-sky-950/80 text-sky-300 border-sky-700/50',
|
||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
|
||||
// Health
|
||||
healthy: {
|
||||
label: 'В норме',
|
||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
degraded: {
|
||||
label: 'Деградация',
|
||||
className: 'bg-amber-950/80 text-amber-400 border-amber-700/50',
|
||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
warning: {
|
||||
label: 'Предупреждение',
|
||||
className: 'bg-amber-950/80 text-amber-400 border-amber-700/50',
|
||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
critical: {
|
||||
label: 'Критический',
|
||||
className: 'bg-red-950/80 text-red-400 border-red-700/50 font-semibold animate-pulse',
|
||||
icon: <ShieldAlert className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
offline: {
|
||||
label: 'Offline / Сбой',
|
||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700/60',
|
||||
icon: <Radio className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
unknown: {
|
||||
label: 'Неизвестно',
|
||||
className: 'bg-zinc-900 text-zinc-500 border-zinc-800',
|
||||
icon: <Radio className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
|
||||
// Installations
|
||||
supported: {
|
||||
label: 'Поддерживается',
|
||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
outdated: {
|
||||
label: 'Устарела',
|
||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
||||
icon: <Smartphone className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
blocked: {
|
||||
label: 'Заблокирована',
|
||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
revoked: {
|
||||
label: 'Отозвана',
|
||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700',
|
||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
|
||||
// Rollout
|
||||
draft: {
|
||||
label: 'Черновик',
|
||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700',
|
||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
pilot: {
|
||||
label: 'Пилот (Pilot)',
|
||||
className: 'bg-indigo-950/80 text-indigo-300 border-indigo-700/50',
|
||||
icon: <Zap className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
percentage: {
|
||||
label: 'Поэтапная',
|
||||
className: 'bg-sky-950/80 text-sky-300 border-sky-700/50',
|
||||
icon: <Server className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
general_availability: {
|
||||
label: 'Релиз (GA)',
|
||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
paused: {
|
||||
label: 'На паузе',
|
||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
rolled_back: {
|
||||
label: 'Откатан (Rollback)',
|
||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
|
||||
// Incidents
|
||||
open: {
|
||||
label: 'Открыт',
|
||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50 font-semibold',
|
||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
mitigating: {
|
||||
label: 'Митигация',
|
||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
resolved: {
|
||||
label: 'Разрешен',
|
||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[status] || configs.unknown;
|
||||
const label = customLabel || config.label;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border font-medium tabular-nums tracking-wide transition-colors ${
|
||||
isSmall ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-xs'
|
||||
} ${config.className}`}
|
||||
data-status={status}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
{config.icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
453
src/features/control-center/contracts/index.ts
Normal file
453
src/features/control-center/contracts/index.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
export type UUID = string;
|
||||
export type IsoDateTime = string;
|
||||
|
||||
export type PlatformStatus = 'active' | 'trial' | 'grace' | 'suspended' | 'closed';
|
||||
export type HealthState = 'healthy' | 'degraded' | 'critical' | 'offline' | 'unknown';
|
||||
export type InstallationKind = 'web' | 'desktop' | 'android';
|
||||
export type InstallationStatus = 'supported' | 'outdated' | 'blocked' | 'revoked';
|
||||
export type RolloutStage = 'draft' | 'pilot' | 'percentage' | 'general_availability' | 'paused' | 'rolled_back';
|
||||
|
||||
export type OperatorRole =
|
||||
| 'platform_owner'
|
||||
| 'platform_admin'
|
||||
| 'support_operator'
|
||||
| 'billing_manager'
|
||||
| 'security_auditor'
|
||||
| 'release_manager';
|
||||
|
||||
export interface OperatorProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
role: OperatorRole;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface OperatorPermissions {
|
||||
canManagePlatformStatus: boolean;
|
||||
canGrantTrialGrace: boolean;
|
||||
canEditEntitlements: boolean;
|
||||
canTriggerRollouts: boolean;
|
||||
canPauseRollbackRollouts: boolean;
|
||||
canManageIncidents: boolean;
|
||||
canCreateAnnouncements: boolean;
|
||||
canScheduleMaintenance: boolean;
|
||||
canRequestDiagnostics: boolean;
|
||||
canExportAudit: boolean;
|
||||
canViewBillingDetails: boolean;
|
||||
}
|
||||
|
||||
export const OPERATOR_ROLE_DEFINITIONS: Record<
|
||||
OperatorRole,
|
||||
{ label: string; description: string; permissions: OperatorPermissions }
|
||||
> = {
|
||||
platform_owner: {
|
||||
label: 'Владелец платформы',
|
||||
description: 'Полный доступ ко всем управленческим, финансовым, релизным и инфраструктурным операциям.',
|
||||
permissions: {
|
||||
canManagePlatformStatus: true,
|
||||
canGrantTrialGrace: true,
|
||||
canEditEntitlements: true,
|
||||
canTriggerRollouts: true,
|
||||
canPauseRollbackRollouts: true,
|
||||
canManageIncidents: true,
|
||||
canCreateAnnouncements: true,
|
||||
canScheduleMaintenance: true,
|
||||
canRequestDiagnostics: true,
|
||||
canExportAudit: true,
|
||||
canViewBillingDetails: true,
|
||||
},
|
||||
},
|
||||
platform_admin: {
|
||||
label: 'Системный администратор',
|
||||
description: 'Управление организациями, инфраструктурой, инцидентами и техническими операциями.',
|
||||
permissions: {
|
||||
canManagePlatformStatus: true,
|
||||
canGrantTrialGrace: true,
|
||||
canEditEntitlements: false,
|
||||
canTriggerRollouts: true,
|
||||
canPauseRollbackRollouts: true,
|
||||
canManageIncidents: true,
|
||||
canCreateAnnouncements: true,
|
||||
canScheduleMaintenance: true,
|
||||
canRequestDiagnostics: true,
|
||||
canExportAudit: true,
|
||||
canViewBillingDetails: false,
|
||||
},
|
||||
},
|
||||
support_operator: {
|
||||
label: 'Оператор поддержки',
|
||||
description: 'Диагностика инцидентов, запрос телеметрии, отправка точечных сервисных сообщений.',
|
||||
permissions: {
|
||||
canManagePlatformStatus: false,
|
||||
canGrantTrialGrace: false,
|
||||
canEditEntitlements: false,
|
||||
canTriggerRollouts: false,
|
||||
canPauseRollbackRollouts: false,
|
||||
canManageIncidents: true,
|
||||
canCreateAnnouncements: true,
|
||||
canScheduleMaintenance: false,
|
||||
canRequestDiagnostics: true,
|
||||
canExportAudit: false,
|
||||
canViewBillingDetails: false,
|
||||
},
|
||||
},
|
||||
billing_manager: {
|
||||
label: 'Биллинг-менеджер',
|
||||
description: 'Управление подписками, тарифами, продлениями, выдача пробных и льготных периодов.',
|
||||
permissions: {
|
||||
canManagePlatformStatus: true,
|
||||
canGrantTrialGrace: true,
|
||||
canEditEntitlements: true,
|
||||
canTriggerRollouts: false,
|
||||
canPauseRollbackRollouts: false,
|
||||
canManageIncidents: false,
|
||||
canCreateAnnouncements: true,
|
||||
canScheduleMaintenance: false,
|
||||
canRequestDiagnostics: false,
|
||||
canExportAudit: false,
|
||||
canViewBillingDetails: true,
|
||||
},
|
||||
},
|
||||
security_auditor: {
|
||||
label: 'Аудитор безопасности',
|
||||
description: 'Read-only доступ ко всем операциям с правом полного аудита и экспорта защищённого журнала.',
|
||||
permissions: {
|
||||
canManagePlatformStatus: false,
|
||||
canGrantTrialGrace: false,
|
||||
canEditEntitlements: false,
|
||||
canTriggerRollouts: false,
|
||||
canPauseRollbackRollouts: false,
|
||||
canManageIncidents: false,
|
||||
canCreateAnnouncements: false,
|
||||
canScheduleMaintenance: false,
|
||||
canRequestDiagnostics: true,
|
||||
canExportAudit: true,
|
||||
canViewBillingDetails: true,
|
||||
},
|
||||
},
|
||||
release_manager: {
|
||||
label: 'Релиз-менеджер',
|
||||
description: 'Управление каталогом модулей, compatibility matrix, запуск пилотных и фазовых раскаток.',
|
||||
permissions: {
|
||||
canManagePlatformStatus: false,
|
||||
canGrantTrialGrace: false,
|
||||
canEditEntitlements: false,
|
||||
canTriggerRollouts: true,
|
||||
canPauseRollbackRollouts: true,
|
||||
canManageIncidents: true,
|
||||
canCreateAnnouncements: true,
|
||||
canScheduleMaintenance: false,
|
||||
canRequestDiagnostics: false,
|
||||
canExportAudit: false,
|
||||
canViewBillingDetails: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export interface OrganizationProjection {
|
||||
tenantId: UUID;
|
||||
displayName: string;
|
||||
inn: string;
|
||||
domain: string;
|
||||
platformStatus: PlatformStatus;
|
||||
userCount: number;
|
||||
branchCount: number;
|
||||
installedModulesCount: number;
|
||||
mrrMinorUnits: number;
|
||||
outstandingDebtMinorUnits: number;
|
||||
currency: string;
|
||||
tariffPlan: string;
|
||||
subscriptionEndAt: IsoDateTime;
|
||||
gracePeriodUntil?: IsoDateTime | null;
|
||||
lastActiveAt: IsoDateTime;
|
||||
sourceRevision: string;
|
||||
projectedAt: IsoDateTime;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
}
|
||||
|
||||
export interface ModuleDefinition {
|
||||
moduleId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
category: 'core' | 'operations' | 'crm' | 'finance' | 'staff' | 'omnichannel' | 'pos' | 'analytics';
|
||||
description: string;
|
||||
latestVersion: string;
|
||||
availableVersions: string[];
|
||||
requiredCapabilities: string[];
|
||||
manifestVersion: '1.0';
|
||||
rolloutChannel: 'stable' | 'pilot' | 'beta';
|
||||
activeTenantsCount: number;
|
||||
instancesCount: number;
|
||||
dependencies: string[];
|
||||
releaseHistory: {
|
||||
version: string;
|
||||
releasedAt: IsoDateTime;
|
||||
changelog: string;
|
||||
compatibleCore: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ModuleEntitlement {
|
||||
tenantId: UUID;
|
||||
moduleId: string;
|
||||
status: 'active' | 'grace' | 'expired' | 'revoked' | 'trial';
|
||||
validFrom: IsoDateTime;
|
||||
validUntil: IsoDateTime | null;
|
||||
graceUntil: IsoDateTime | null;
|
||||
planCode: string;
|
||||
grantedReason?: string;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
subscriptionId: UUID;
|
||||
tenantId: UUID;
|
||||
tenantName: string;
|
||||
billingAccountRef: string;
|
||||
planCode: string;
|
||||
period: 'month' | 'year';
|
||||
amountMinorUnits: number;
|
||||
currency: string;
|
||||
status: 'normal' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace' | 'suspended';
|
||||
renewsAt: IsoDateTime | null;
|
||||
validFrom: IsoDateTime;
|
||||
validUntil: IsoDateTime;
|
||||
graceUntil: IsoDateTime | null;
|
||||
activeModules: string[];
|
||||
autoRenew: boolean;
|
||||
history: {
|
||||
eventAt: IsoDateTime;
|
||||
type: 'renewed' | 'upgraded' | 'grace_granted' | 'trial_added' | 'plan_changed';
|
||||
description: string;
|
||||
actor: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Installation {
|
||||
installationId: UUID;
|
||||
tenantId: UUID;
|
||||
tenantName: string;
|
||||
kind: InstallationKind;
|
||||
deviceName: string;
|
||||
osVersion: string;
|
||||
appVersion: string;
|
||||
status: InstallationStatus;
|
||||
registeredAt: IsoDateTime;
|
||||
lastSeenAt: IsoDateTime;
|
||||
ipAddressMasked: string;
|
||||
}
|
||||
|
||||
export interface ModuleInstance {
|
||||
moduleInstanceId: UUID;
|
||||
tenantId: UUID;
|
||||
tenantName: string;
|
||||
moduleId: string;
|
||||
moduleName: string;
|
||||
deploymentId: UUID;
|
||||
serverId: UUID;
|
||||
serverName: string;
|
||||
mode: 'standalone' | 'platform';
|
||||
currentVersion: string;
|
||||
health: HealthState;
|
||||
lastHeartbeatAt: IsoDateTime;
|
||||
uptimeSeconds: number;
|
||||
}
|
||||
|
||||
export interface Deployment {
|
||||
deploymentId: UUID;
|
||||
tenantId: UUID;
|
||||
environment: 'production' | 'staging' | 'development';
|
||||
provider: string;
|
||||
region: string;
|
||||
rolloutStatus: 'pending' | 'running' | 'succeeded' | 'failed' | 'rolled_back';
|
||||
createdAt: IsoDateTime;
|
||||
updatedAt: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
state: 'ok' | 'overdue' | 'failed' | 'unknown';
|
||||
lastSuccessfulAt: IsoDateTime | null;
|
||||
sizeBytes?: number;
|
||||
encryptedExternalCopy: boolean;
|
||||
targetProvider: string;
|
||||
}
|
||||
|
||||
export interface RestoreDrillStatus {
|
||||
state: 'passed' | 'failed' | 'overdue' | 'never';
|
||||
lastAttemptAt: IsoDateTime | null;
|
||||
verifiedAt: IsoDateTime | null;
|
||||
drillDurationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface ServerNode {
|
||||
serverId: UUID;
|
||||
name: string;
|
||||
hostname: string;
|
||||
ipAddress: string;
|
||||
hostingProvider: 'Selectel' | 'Timeweb Cloud' | 'VK Cloud' | 'Self-hosted';
|
||||
region: string;
|
||||
status: 'healthy' | 'warning' | 'critical' | 'offline';
|
||||
agentVersion: string;
|
||||
agentStatus: 'enrolled' | 'pending' | 'expired' | 'unreachable';
|
||||
certificateExpiresAt: IsoDateTime;
|
||||
certificateDaysRemaining: number;
|
||||
cpuLoadPercent: number;
|
||||
ramUsedBytes: number;
|
||||
ramTotalBytes: number;
|
||||
diskUsedBytes: number;
|
||||
diskTotalBytes: number;
|
||||
inodeUsedPercent: number;
|
||||
networkRxBytesPerSec: number;
|
||||
networkTxBytesPerSec: number;
|
||||
uptimeSeconds: number;
|
||||
latencyMs: number;
|
||||
deployedInstancesCount: number;
|
||||
lastSeenAt: IsoDateTime;
|
||||
backup: BackupStatus;
|
||||
restoreDrill: RestoreDrillStatus;
|
||||
}
|
||||
|
||||
export interface HeartbeatPoint {
|
||||
timestamp: IsoDateTime;
|
||||
state: HealthState;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface HeartbeatEnvelope {
|
||||
contractVersion: '1.0';
|
||||
tenantId: UUID;
|
||||
moduleId: string;
|
||||
moduleInstanceId: UUID;
|
||||
deploymentId: UUID;
|
||||
environment: string;
|
||||
hosting: { provider: string; region: string };
|
||||
mode: 'standalone' | 'platform';
|
||||
versions: { application: string; api: string; schema: string };
|
||||
health: HealthState;
|
||||
readiness: boolean;
|
||||
migrations: { status: 'current' | 'pending' | 'failed'; version: string };
|
||||
queue: { status: HealthState; depth: number };
|
||||
outbox: { status: HealthState; pending: number; oldestPendingAt: IsoDateTime | null };
|
||||
backup: BackupStatus;
|
||||
restoreDrill: RestoreDrillStatus;
|
||||
observedAt: IsoDateTime;
|
||||
correlationId: string;
|
||||
trust: { transport: 'mtls' | 'signed'; keyId?: string; signature?: string };
|
||||
}
|
||||
|
||||
export interface HealthSnapshot {
|
||||
subjectType: 'module' | 'deployment' | 'server';
|
||||
subjectId: UUID;
|
||||
state: HealthState;
|
||||
observedAt: IsoDateTime;
|
||||
stale: boolean;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UsageAggregate {
|
||||
tenantId: UUID;
|
||||
moduleId: string;
|
||||
metric: string;
|
||||
periodStart: IsoDateTime;
|
||||
periodEnd: IsoDateTime;
|
||||
value: number;
|
||||
dimensions: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
announcementId: UUID;
|
||||
audience: {
|
||||
allTenants?: boolean;
|
||||
tenantIds?: UUID[];
|
||||
targetPlan?: string;
|
||||
requiredModuleId?: string;
|
||||
minAppVersion?: string;
|
||||
};
|
||||
title: string;
|
||||
body: string;
|
||||
priority: 'normal' | 'important' | 'critical';
|
||||
channels: ('in_app' | 'comm_adapter')[];
|
||||
status: 'draft' | 'scheduled' | 'sending' | 'completed' | 'failed';
|
||||
validFrom: IsoDateTime;
|
||||
validUntil: IsoDateTime | null;
|
||||
createdAt: IsoDateTime;
|
||||
author: string;
|
||||
stats: {
|
||||
totalTargeted: number;
|
||||
queued: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeliveryStatus {
|
||||
announcementId: UUID;
|
||||
tenantId: UUID;
|
||||
tenantName: string;
|
||||
channel: string;
|
||||
status: 'queued' | 'sent' | 'delivered' | 'failed';
|
||||
updatedAt: IsoDateTime;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
incidentId: UUID;
|
||||
incidentNumber: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'open' | 'mitigating' | 'resolved';
|
||||
summary: string;
|
||||
description: string;
|
||||
affectedTenantIds: UUID[];
|
||||
affectedServerIds: UUID[];
|
||||
affectedModuleIds: string[];
|
||||
ownerName: string;
|
||||
openedAt: IsoDateTime;
|
||||
resolvedAt?: IsoDateTime | null;
|
||||
timeline: {
|
||||
timestamp: IsoDateTime;
|
||||
author: string;
|
||||
message: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
defaultValue: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Rollout {
|
||||
rolloutId: UUID;
|
||||
moduleId: string;
|
||||
moduleName: string;
|
||||
targetVersion: string;
|
||||
stage: RolloutStage;
|
||||
pilotTenantIds: UUID[];
|
||||
percentage: number;
|
||||
startedAt: IsoDateTime;
|
||||
updatedAt: IsoDateTime;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface OperatorAuditEvent {
|
||||
eventId: UUID;
|
||||
occurredAt: IsoDateTime;
|
||||
operatorId: string;
|
||||
operatorName: string;
|
||||
operatorRole: OperatorRole;
|
||||
action: string;
|
||||
target: {
|
||||
type: 'organization' | 'subscription' | 'module' | 'rollout' | 'infrastructure' | 'announcement' | 'incident';
|
||||
id: string;
|
||||
displayName?: string;
|
||||
tenantId?: UUID;
|
||||
};
|
||||
reason: string;
|
||||
impactSummary: string;
|
||||
correlationId: string;
|
||||
idempotencyKey?: string;
|
||||
previousHash: string;
|
||||
hash: string;
|
||||
}
|
||||
1700
src/features/control-center/fixtures/seedData.ts
Normal file
1700
src/features/control-center/fixtures/seedData.ts
Normal file
File diff suppressed because it is too large
Load diff
33
src/features/control-center/hooks/index.ts
Normal file
33
src/features/control-center/hooks/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { controlCenterApi } from '../api';
|
||||
|
||||
export function useControlCenter() {
|
||||
const store = useKccStore();
|
||||
const operator = useOperatorRole();
|
||||
|
||||
return {
|
||||
...store,
|
||||
api: controlCenterApi,
|
||||
role: operator.role,
|
||||
operator: operator.operator,
|
||||
permissions: operator.permissions,
|
||||
};
|
||||
}
|
||||
|
||||
export function useOperatorPermissions() {
|
||||
const { permissions, role } = useOperatorRole();
|
||||
return { permissions, role };
|
||||
}
|
||||
|
||||
export function useInspector() {
|
||||
const target = useKccStore((state) => state.inspectorTarget);
|
||||
const openInspector = useKccStore((state) => state.openInspector);
|
||||
const closeInspector = useKccStore((state) => state.closeInspector);
|
||||
|
||||
return {
|
||||
target,
|
||||
openInspector,
|
||||
closeInspector,
|
||||
isOpen: !!target,
|
||||
};
|
||||
}
|
||||
272
src/features/control-center/pages/AnnouncementsPage.tsx
Normal file
272
src/features/control-center/pages/AnnouncementsPage.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
BellRing,
|
||||
Send,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
|
||||
export const AnnouncementsPage: React.FC = () => {
|
||||
const announcements = useKccStore((state) => state.announcements);
|
||||
const organizations = useKccStore((state) => state.organizations);
|
||||
const modules = useKccStore((state) => state.modules);
|
||||
const createAnnouncement = useKccStore((state) => state.createAnnouncement);
|
||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||
const { permissions } = useOperatorRole();
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [priority, setPriority] = useState<'normal' | 'important' | 'critical'>('normal');
|
||||
const [audienceType, setAudienceType] = useState<'all' | 'specific' | 'module'>('module');
|
||||
const [selectedModuleId, setSelectedModuleId] = useState('krona-omnichannel');
|
||||
const [selectedTenants] = useState<string[]>([
|
||||
organizations[0]?.tenantId || '',
|
||||
organizations[1]?.tenantId || '',
|
||||
].filter(Boolean));
|
||||
|
||||
const handleSendAnnouncement = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !body.trim()) return;
|
||||
|
||||
let tenantIds: string[] | undefined = undefined;
|
||||
let allTenants = false;
|
||||
|
||||
if (audienceType === 'all') {
|
||||
allTenants = true;
|
||||
} else if (audienceType === 'specific') {
|
||||
tenantIds = selectedTenants;
|
||||
} else if (audienceType === 'module') {
|
||||
tenantIds = organizations.slice(0, 4).map((o) => o.tenantId);
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: `Отправка оповещения: ${title}`,
|
||||
actionName: 'announcement.publish_broadcast',
|
||||
impactSummary: `Оповещение будет доставлено через In-App канал ${allTenants ? 'всем организациям' : `${tenantIds?.length || 0} выбранным организациям`}.`,
|
||||
targetType: 'announcement',
|
||||
targetId: `new-announcement`,
|
||||
targetName: title,
|
||||
onConfirm: async (reason) => {
|
||||
await createAnnouncement(
|
||||
{
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
priority,
|
||||
channels: ['in_app', 'comm_adapter'],
|
||||
status: 'completed',
|
||||
validFrom: new Date().toISOString(),
|
||||
validUntil: null,
|
||||
audience: {
|
||||
allTenants,
|
||||
tenantIds,
|
||||
requiredModuleId: audienceType === 'module' ? selectedModuleId : undefined,
|
||||
},
|
||||
},
|
||||
reason
|
||||
);
|
||||
setIsCreating(false);
|
||||
setTitle('');
|
||||
setBody('');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<BellRing className="w-5 h-5 text-amber-400" />
|
||||
<span>Оповещения и системные сообщения</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Таргетированные сервисные рассылки, уведомления об обновлениях модулей и плановом ТО
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canCreateAnnouncements}
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
className="px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center gap-1.5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>{isCreating ? 'Закрыть редактор' : 'Создать объявление'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Announcement Form (Сценарий 7) */}
|
||||
{isCreating && (
|
||||
<form
|
||||
onSubmit={handleSendAnnouncement}
|
||||
className="rounded-xl border border-amber-500/40 bg-[#0a1c15] p-5 space-y-4 shadow-xl animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
<div className="flex justify-between items-center border-b border-zinc-800 pb-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-zinc-100">
|
||||
<Send className="w-4 h-4 text-amber-400" />
|
||||
<span>Конструктор операторского оповещения</span>
|
||||
</div>
|
||||
<span className="text-xs text-amber-300/80 font-mono">DEMO: без внешней отправки SMS/Email</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-semibold text-zinc-300">Заголовок оповещения:</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Например: Доступен модуль Omnichannel 3.0"
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg p-2.5 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-semibold text-zinc-300">Приоритет доставки:</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as typeof priority)}
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg p-2.5 text-xs text-zinc-100 focus:outline-hidden"
|
||||
>
|
||||
<option value="normal">Обычный (Информационный баннер)</option>
|
||||
<option value="important">Важный (Уведомление в шапке)</option>
|
||||
<option value="critical">Критический (Блокирующий модальный диалог)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audience selection */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<label className="font-semibold text-zinc-300">Целевая аудитория тенантов:</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudienceType('all')}
|
||||
className={`p-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
audienceType === 'all'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
||||
: 'bg-zinc-950 text-zinc-400 border-zinc-800'
|
||||
}`}
|
||||
>
|
||||
Все организации ({organizations.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudienceType('module')}
|
||||
className={`p-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
audienceType === 'module'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
||||
: 'bg-zinc-950 text-zinc-400 border-zinc-800'
|
||||
}`}
|
||||
>
|
||||
По установленному модулю
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudienceType('specific')}
|
||||
className={`p-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
audienceType === 'specific'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
||||
: 'bg-zinc-950 text-zinc-400 border-zinc-800'
|
||||
}`}
|
||||
>
|
||||
Выборочные организации ({selectedTenants.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{audienceType === 'module' && (
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<span className="text-zinc-400 text-xs">Выберите модуль:</span>
|
||||
<select
|
||||
value={selectedModuleId}
|
||||
onChange={(e) => setSelectedModuleId(e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-md px-2.5 py-1 text-xs text-zinc-200"
|
||||
>
|
||||
{modules.map((m) => (
|
||||
<option key={m.moduleId} value={m.moduleId}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<label className="font-semibold text-zinc-300">Текст сообщения:</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Введите текст объявления для операторов и пользователей организаций..."
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg p-2.5 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreating(false)}
|
||||
className="px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 text-xs font-medium"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center gap-1.5 shadow-md shadow-amber-900/30"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Опубликовать (Reason + Audit)</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Announcements List */}
|
||||
<div className="space-y-3">
|
||||
{announcements.map((anc) => (
|
||||
<div key={anc.announcementId} className="rounded-xl border border-zinc-800 bg-[#091a13] p-5 space-y-3 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-zinc-850 pb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-bold text-sm text-zinc-100">{anc.title}</span>
|
||||
<StatusBadge status={anc.status === 'completed' ? 'healthy' : 'warning'} size="sm" customLabel={anc.status.toUpperCase()} />
|
||||
<span className="text-[10px] uppercase font-bold text-amber-400 px-1.5 py-0.5 rounded-xs bg-amber-500/10 border border-amber-500/30">
|
||||
{anc.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
Автор: <span className="text-zinc-200">{anc.author}</span> • Опубликовано:{' '}
|
||||
{new Date(anc.createdAt).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery statistics */}
|
||||
<div className="flex items-center gap-3 text-xs shrink-0 font-mono">
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-zinc-500">Доставлено</div>
|
||||
<div className="font-bold text-emerald-400">{anc.stats.delivered} / {anc.stats.totalTargeted}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-zinc-500">Каналы</div>
|
||||
<div className="text-zinc-300 font-semibold">{anc.channels.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-300 leading-relaxed">{anc.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
192
src/features/control-center/pages/AuditPage.tsx
Normal file
192
src/features/control-center/pages/AuditPage.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
FileCheck2,
|
||||
Search,
|
||||
Download,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
|
||||
export const AuditPage: React.FC = () => {
|
||||
const auditLog = useKccStore((state) => state.auditLog);
|
||||
const { permissions } = useOperatorRole();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
|
||||
const filtered = auditLog.filter((item) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
item.operatorName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.reason.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.action.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.correlationId.toLowerCase().includes(search.toLowerCase());
|
||||
return matchSearch;
|
||||
});
|
||||
|
||||
const selectedEvent = auditLog.find((e) => e.eventId === selectedEventId);
|
||||
|
||||
const handleExportJson = () => {
|
||||
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(auditLog, null, 2));
|
||||
const downloadAnchor = document.createElement('a');
|
||||
downloadAnchor.setAttribute('href', dataStr);
|
||||
downloadAnchor.setAttribute('download', `kcc-immutable-audit-${Date.now()}.json`);
|
||||
document.body.appendChild(downloadAnchor);
|
||||
downloadAnchor.click();
|
||||
downloadAnchor.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<FileCheck2 className="w-5 h-5 text-amber-400" />
|
||||
<span>Неизменяемый журнал аудита действий операторов</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Криптографическая цепочка событий (SHA-256 Hash Chain). Записи защищены от редактирования и удаления.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canExportAudit}
|
||||
onClick={handleExportJson}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-200 text-xs font-semibold flex items-center gap-1.5 transition-colors disabled:opacity-40"
|
||||
title="Экспорт неизменяемого журнала для регулятора/аудитора"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Экспорт JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Badge */}
|
||||
<div className="rounded-xl border border-emerald-500/40 bg-emerald-950/20 p-3.5 flex items-center justify-between text-xs text-emerald-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
<span>Целостность журнала аудита подтверждена: <strong>{auditLog.length} событий</strong> в цепочке хешей.</span>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-emerald-400/80">WORM (Write Once, Read Many)</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по оператору, действию, correlation ID или обоснованию..."
|
||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audit Log Table */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
||||
<th className="py-3 px-4">Время (UTC)</th>
|
||||
<th className="py-3 px-3">Оператор / Роль</th>
|
||||
<th className="py-3 px-3 font-mono">Действие</th>
|
||||
<th className="py-3 px-3">Целевой объект</th>
|
||||
<th className="py-3 px-3">Обоснование (Reason)</th>
|
||||
<th className="py-3 px-3 font-mono">Correlation ID</th>
|
||||
<th className="py-3 px-4 text-right font-mono">SHA-256 Hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{filtered.map((item) => (
|
||||
<tr
|
||||
key={item.eventId}
|
||||
onClick={() => setSelectedEventId(item.eventId)}
|
||||
className="hover:bg-zinc-800/40 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="py-3 px-4 font-mono text-zinc-400 text-[11px] whitespace-nowrap">
|
||||
{new Date(item.occurredAt).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="font-bold text-zinc-200">{item.operatorName}</div>
|
||||
<div className="text-[10px] text-amber-400/80 font-mono uppercase">{item.operatorRole}</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 font-mono text-amber-300 text-[11px]">{item.action}</td>
|
||||
<td className="py-3 px-3 text-zinc-300">
|
||||
<span className="font-semibold">{item.target.displayName || item.target.id}</span>
|
||||
<span className="text-[10px] text-zinc-500 block font-mono">type: {item.target.type}</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-zinc-200 max-w-xs truncate" title={item.reason}>
|
||||
{item.reason}
|
||||
</td>
|
||||
<td className="py-3 px-3 font-mono text-zinc-400 text-[11px]">{item.correlationId}</td>
|
||||
<td className="py-3 px-4 text-right font-mono text-zinc-500 text-[10px]" title={item.hash}>
|
||||
{item.hash.slice(0, 10)}...{item.hash.slice(-6)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-zinc-500">
|
||||
Записи аудита не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected event modal / details */}
|
||||
{selectedEvent && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-[#0d1e17] p-6 text-zinc-200 space-y-4 shadow-2xl">
|
||||
<div className="flex justify-between items-start border-b border-zinc-800 pb-3">
|
||||
<div>
|
||||
<div className="text-xs font-mono text-amber-400">{selectedEvent.eventId}</div>
|
||||
<h3 className="text-base font-bold text-zinc-100">{selectedEvent.action}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedEventId(null)}
|
||||
className="text-zinc-400 hover:text-zinc-200 text-xs px-2 py-1 rounded-md bg-zinc-800"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1.5">
|
||||
<div className="text-zinc-400">Обоснование действия:</div>
|
||||
<div className="font-medium text-zinc-100">{selectedEvent.reason}</div>
|
||||
<div className="text-zinc-400 text-[11px] pt-1 border-t border-zinc-850">
|
||||
Влияние: <span className="text-amber-300">{selectedEvent.impactSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1 font-mono text-[11px]">
|
||||
<div className="text-zinc-400">Previous Hash (N-1):</div>
|
||||
<div className="text-zinc-500 break-all">{selectedEvent.previousHash}</div>
|
||||
<div className="text-zinc-400 pt-1">Current Hash (SHA-256):</div>
|
||||
<div className="text-emerald-400 break-all font-bold">{selectedEvent.hash}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
320
src/features/control-center/pages/BillingPage.tsx
Normal file
320
src/features/control-center/pages/BillingPage.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
CreditCard,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
|
||||
export const BillingPage: React.FC = () => {
|
||||
const subscriptions = useKccStore((state) => state.subscriptions);
|
||||
const modules = useKccStore((state) => state.modules);
|
||||
const renewSubscription = useKccStore((state) => state.renewSubscription);
|
||||
const grantGracePeriod = useKccStore((state) => state.grantGracePeriod);
|
||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||
const { permissions } = useOperatorRole();
|
||||
|
||||
const [filter, setFilter] = useState<'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace'>('all');
|
||||
const [selectedSubId, setSelectedSubId] = useState<string | null>(subscriptions[0]?.subscriptionId || null);
|
||||
|
||||
const selectedSub = subscriptions.find((s) => s.subscriptionId === selectedSubId) || subscriptions[0];
|
||||
|
||||
// Form state
|
||||
const [newPeriod, setNewPeriod] = useState<'month' | 'year'>('year');
|
||||
const [customModules, setCustomModules] = useState<string[] | null>(null);
|
||||
|
||||
const activeModulesToDisplay = customModules ?? (selectedSub ? selectedSub.activeModules : []);
|
||||
|
||||
const filteredSubs = subscriptions.filter((sub) => {
|
||||
if (filter === 'all') return true;
|
||||
return sub.status === filter;
|
||||
});
|
||||
|
||||
// Calculate live preview amount
|
||||
const baseRatePerMonth = 1500000; // 15 000,00 ₽ базовый
|
||||
const moduleRatePerMonth = 400000; // 4 000,00 ₽ за модуль
|
||||
const monthlyTotal = baseRatePerMonth + activeModulesToDisplay.length * moduleRatePerMonth;
|
||||
const calculatedTotal = newPeriod === 'year' ? monthlyTotal * 10 : monthlyTotal;
|
||||
|
||||
const handleRenew = () => {
|
||||
if (!selectedSub) return;
|
||||
openConfirmModal({
|
||||
title: `Продление подписки: ${selectedSub.tenantName}`,
|
||||
actionName: `subscription.renew (${newPeriod})`,
|
||||
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей (${activeModulesToDisplay.length}): ${activeModulesToDisplay.join(', ')}. Расчетная сумма: ${calculatedTotal / 100} ₽.`,
|
||||
targetType: 'subscription',
|
||||
targetId: selectedSub.subscriptionId,
|
||||
targetName: selectedSub.tenantName,
|
||||
onConfirm: async (reason) => {
|
||||
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGrantGrace = () => {
|
||||
if (!selectedSub) return;
|
||||
openConfirmModal({
|
||||
title: `Предоставление Grace Period: ${selectedSub.tenantName}`,
|
||||
actionName: 'subscription.grace_period_granted',
|
||||
impactSummary: `Организации будет предоставлена отсрочка блокировки на 10 дней без остановки сервисов.`,
|
||||
targetType: 'subscription',
|
||||
targetId: selectedSub.subscriptionId,
|
||||
targetName: selectedSub.tenantName,
|
||||
onConfirm: async (reason) => {
|
||||
await grantGracePeriod(selectedSub.subscriptionId, 10, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-amber-400" />
|
||||
<span>Подписки, лицензии и Entitlements</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Управление правами доступа, сроками действия тарифов и составом включенных модулей
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
|
||||
{[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'expiring_7d', label: 'Истекает 7 дней' },
|
||||
{ id: 'expiring_30d', label: 'Истекает 30 дней' },
|
||||
{ id: 'grace', label: 'Grace Period' },
|
||||
{ id: 'suspended', label: 'Просрочено' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setFilter(tab.id as typeof filter)}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||
filter === tab.id
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
|
||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid: Subscriptions Table + Simulator Panel */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Left 2 Columns: Subscriptions List */}
|
||||
<div className="xl:col-span-2 rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
||||
<th className="py-3 px-4">Организация / Счёт</th>
|
||||
<th className="py-3 px-3">Статус</th>
|
||||
<th className="py-3 px-3">Период</th>
|
||||
<th className="py-3 px-3 text-right">Сумма тарифа</th>
|
||||
<th className="py-3 px-3">Срок действия</th>
|
||||
<th className="py-3 px-3">Grace</th>
|
||||
<th className="py-3 px-4 text-center">Выбор</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{filteredSubs.map((sub) => {
|
||||
const isSelected = sub.subscriptionId === (selectedSub?.subscriptionId ?? '');
|
||||
return (
|
||||
<tr
|
||||
key={sub.subscriptionId}
|
||||
onClick={() => {
|
||||
setSelectedSubId(sub.subscriptionId);
|
||||
setCustomModules(null);
|
||||
setNewPeriod(sub.period);
|
||||
}}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-amber-500/10 border-l-2 border-amber-400' : 'hover:bg-zinc-800/40'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-bold text-zinc-100">{sub.tenantName}</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">{sub.billingAccountRef}</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<StatusBadge status={sub.status} size="sm" />
|
||||
</td>
|
||||
<td className="py-3 px-3 text-zinc-300 font-medium">
|
||||
{sub.period === 'year' ? 'Годовой' : 'Месячный'}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
||||
</td>
|
||||
<td className="py-3 px-3 text-zinc-300 font-mono text-[11px]">
|
||||
до {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-zinc-400 text-[11px]">
|
||||
{sub.graceUntil ? new Date(sub.graceUntil).toLocaleDateString('ru-RU') : '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="selected_sub"
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
setSelectedSubId(sub.subscriptionId);
|
||||
setCustomModules(null);
|
||||
setNewPeriod(sub.period);
|
||||
}}
|
||||
className="accent-amber-400 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Live Renewal & Module Composition Simulator */}
|
||||
<div className="space-y-6">
|
||||
{selectedSub ? (
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4 shadow-xl">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-zinc-100">
|
||||
<SlidersHorizontal className="w-4 h-4 text-amber-400" />
|
||||
<span>Продление и состав модулей (Preview)</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800 text-xs space-y-1">
|
||||
<div className="text-zinc-400">Организация:</div>
|
||||
<div className="font-bold text-zinc-200 text-sm">{selectedSub.tenantName}</div>
|
||||
<div className="text-[11px] text-zinc-500 font-mono">{selectedSub.billingAccountRef}</div>
|
||||
</div>
|
||||
|
||||
{/* Period selection */}
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<label className="font-semibold text-zinc-300">Период действия тарифа:</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewPeriod('month')}
|
||||
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
|
||||
newPeriod === 'month'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
||||
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
1 месяц
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewPeriod('year')}
|
||||
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
|
||||
newPeriod === 'year'
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
||||
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
1 год (скидка 17%)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules selection */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center font-semibold text-zinc-300">
|
||||
<span>Включенные модули:</span>
|
||||
<span className="text-amber-400 font-mono">{activeModulesToDisplay.length} выбрано</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{modules.map((m) => {
|
||||
const isChecked = activeModulesToDisplay.includes(m.moduleId);
|
||||
const isCore = m.moduleId === 'krona-core';
|
||||
return (
|
||||
<label
|
||||
key={m.moduleId}
|
||||
className={`flex items-center justify-between p-2 rounded-lg border text-xs cursor-pointer transition-colors ${
|
||||
isChecked ? 'bg-zinc-900/90 border-zinc-700 text-zinc-100' : 'bg-zinc-950/40 border-zinc-800/60 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={isCore}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setCustomModules([...activeModulesToDisplay, m.moduleId]);
|
||||
else setCustomModules(activeModulesToDisplay.filter((id) => id !== m.moduleId));
|
||||
}}
|
||||
className="accent-amber-400"
|
||||
/>
|
||||
<span className="font-medium">{m.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-400 font-mono">v{m.latestVersion}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price calculation preview */}
|
||||
<div className="p-3.5 rounded-xl bg-zinc-950 border border-amber-500/30 text-xs space-y-2">
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<span>Расчетная сумма (Preview):</span>
|
||||
<MoneyDisplay minorUnits={calculatedTotal} className="text-sm font-bold text-amber-400" />
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
* Демо-расчет стоимости лицензии (minor units RUB). Реальные транзакции не производятся.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canEditEntitlements}
|
||||
onClick={handleRenew}
|
||||
className="w-full py-2 px-4 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center justify-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>Продлить тариф с новым составом</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canGrantTrialGrace}
|
||||
onClick={handleGrantGrace}
|
||||
className="w-full py-2 px-4 rounded-lg bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-300 font-medium text-xs transition-colors disabled:opacity-40"
|
||||
>
|
||||
Предоставить льготу (Grace 10 дней)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subscription Timeline History */}
|
||||
<div className="pt-3 border-t border-zinc-800 space-y-2 text-xs">
|
||||
<div className="font-semibold text-zinc-300">История изменений подписки:</div>
|
||||
<div className="space-y-1.5 text-[11px]">
|
||||
{selectedSub.history.map((h, i) => (
|
||||
<div key={i} className="p-2 rounded-md bg-zinc-950/60 border border-zinc-850">
|
||||
<div className="flex justify-between text-zinc-400 text-[10px]">
|
||||
<span className="font-bold text-zinc-300">{h.actor}</span>
|
||||
<span>{new Date(h.eventAt).toLocaleDateString('ru-RU')}</span>
|
||||
</div>
|
||||
<div className="text-zinc-200 mt-0.5">{h.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 rounded-xl border border-zinc-800 bg-[#0a1c15] text-center text-xs text-zinc-500">
|
||||
Выберите подписку в таблице слева для управления
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
82
src/features/control-center/pages/ControlAccessGatePage.tsx
Normal file
82
src/features/control-center/pages/ControlAccessGatePage.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lock, ArrowRight } from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
|
||||
export const ControlAccessGatePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const setAccessGatePassed = useKccStore((state) => state.setAccessGatePassed);
|
||||
const [pin, setPin] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pin === 'krona2026' || pin === 'kcc' || pin === 'admin') {
|
||||
setAccessGatePassed(true);
|
||||
navigate('/control');
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoBypass = () => {
|
||||
setAccessGatePassed(true);
|
||||
navigate('/control');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#07130e] text-zinc-100 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-800 bg-[#0d1e17] p-8 text-center space-y-6 shadow-2xl">
|
||||
<div className="w-16 h-16 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-400 mx-auto">
|
||||
<Lock className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-mono font-bold uppercase tracking-widest text-amber-400">
|
||||
KRONA CONTROL CENTER
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-zinc-100 mt-1">Operator Access Gate</h1>
|
||||
<p className="text-xs text-zinc-400 mt-2">
|
||||
Демонстрационный стенд закрытого кабинета оператора платформы
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={pin}
|
||||
onChange={(e) => {
|
||||
setPin(e.target.value);
|
||||
if (error) setError(false);
|
||||
}}
|
||||
placeholder="Введите пин-код доступа (krona2026)..."
|
||||
className="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-4 py-3 text-center text-sm text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 tracking-wider font-mono"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400 font-medium mt-1.5">Неверный код доступа. Попробуйте krona2026.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 px-4 rounded-xl bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-lg shadow-amber-900/30"
|
||||
>
|
||||
<span>Войти в консоль оператора</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDemoBypass}
|
||||
className="text-xs text-zinc-400 hover:text-amber-300 transition-colors underline underline-offset-4"
|
||||
>
|
||||
Быстрый демо-вход в 1 клик (Reviewer Mode)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
src/features/control-center/pages/ControlForbiddenPage.tsx
Normal file
41
src/features/control-center/pages/ControlForbiddenPage.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ShieldAlert, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export const ControlForbiddenPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#07130e] text-zinc-100 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md rounded-2xl border border-rose-850 bg-[#0d1e17] p-8 text-center space-y-5 shadow-2xl">
|
||||
<div className="w-16 h-16 rounded-2xl bg-rose-950/60 border border-rose-700/50 flex items-center justify-center text-rose-400 mx-auto">
|
||||
<ShieldAlert className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-widest text-rose-400">
|
||||
HTTP 403 FORBIDDEN
|
||||
</span>
|
||||
<h1 className="text-2xl font-black text-zinc-100 mt-1">Доступ закрыт</h1>
|
||||
<p className="text-xs text-zinc-400 mt-2 leading-relaxed">
|
||||
Раздел Krona Control Center предназначен исключительно для авторизованных системных операторов платформы
|
||||
(Platform Owner / Platform Admin / Support / Billing).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 text-xs text-zinc-400">
|
||||
Если вы оператор демонстрационного стенда, переключитесь на роль платформы в сессионном переключателе или
|
||||
авторизуйтесь через Access Gate.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<NavLink
|
||||
to="/"
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-200 text-xs font-semibold flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Вернуться в клиентское приложение</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
140
src/features/control-center/pages/IncidentsPage.tsx
Normal file
140
src/features/control-center/pages/IncidentsPage.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Search,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { Incident } from '../contracts';
|
||||
|
||||
export const IncidentsPage: React.FC = () => {
|
||||
const incidents = useKccStore((state) => state.incidents);
|
||||
const openInspector = useKccStore((state) => state.openInspector);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | Incident['status']>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = incidents.filter((inc) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
inc.incidentNumber.toLowerCase().includes(search.toLowerCase()) ||
|
||||
inc.summary.toLowerCase().includes(search.toLowerCase()) ||
|
||||
inc.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = statusFilter === 'all' || inc.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-rose-400" />
|
||||
<span>Инциденты платформы (Incidents Desk)</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Оперативное реагирование на сбои серверных узлов, деградацию модулей и инфраструктурные аварии
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-400">
|
||||
Всего инцидентов: <span className="font-bold text-zinc-100">{incidents.length}</span> (открыто:{' '}
|
||||
<span className="font-bold text-rose-400">{incidents.filter((i) => i.status !== 'resolved').length}</span>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по номеру, описанию или теме..."
|
||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 text-xs">
|
||||
{[
|
||||
{ id: 'all', label: 'Все инциденты' },
|
||||
{ id: 'open', label: 'Открытые' },
|
||||
{ id: 'mitigating', label: 'В процессе митигации' },
|
||||
{ id: 'resolved', label: 'Разрешенные' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||
statusFilter === tab.id
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Incidents List */}
|
||||
<div className="space-y-3">
|
||||
{filtered.map((inc) => (
|
||||
<div
|
||||
key={inc.incidentId}
|
||||
onClick={() => openInspector('incident', inc.incidentId)}
|
||||
className="p-5 rounded-xl border border-zinc-800 bg-[#091a13] hover:border-zinc-700 cursor-pointer transition-colors space-y-3 shadow-lg group"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-zinc-850 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-mono font-bold text-sm text-zinc-100 group-hover:text-amber-300 transition-colors">
|
||||
{inc.incidentNumber}
|
||||
</span>
|
||||
<StatusBadge status={inc.status} size="sm" />
|
||||
<span className="text-[10px] uppercase font-extrabold px-2 py-0.5 rounded-xs bg-rose-950/80 text-rose-300 border border-rose-800/60 font-mono">
|
||||
{inc.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-400">
|
||||
<span>Ответственный: <strong className="text-zinc-200">{inc.ownerName}</strong></span>
|
||||
<span>•</span>
|
||||
<span>{new Date(inc.openedAt).toLocaleString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-sm text-zinc-100">{inc.summary}</h3>
|
||||
<p className="text-xs text-zinc-300 leading-relaxed">{inc.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-zinc-850 flex items-center justify-between text-xs text-zinc-400">
|
||||
<div className="flex gap-4">
|
||||
<span>Затронуто тенантов: <strong className="text-zinc-200">{inc.affectedTenantIds.length}</strong></span>
|
||||
<span>Узлов: <strong className="text-zinc-200">{inc.affectedServerIds.length}</strong></span>
|
||||
<span>Модулей: <strong className="text-zinc-200">{inc.affectedModuleIds.length}</strong></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 font-medium"
|
||||
>
|
||||
<span>Детали расследования</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="p-8 rounded-xl border border-zinc-800 bg-[#091a13] text-center text-xs text-zinc-500">
|
||||
Инциденты не найдены
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
268
src/features/control-center/pages/InfrastructurePage.tsx
Normal file
268
src/features/control-center/pages/InfrastructurePage.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Server,
|
||||
Search,
|
||||
PlusCircle,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
||||
import { ServerNode } from '../contracts';
|
||||
|
||||
export const InfrastructurePage: React.FC = () => {
|
||||
const servers = useKccStore((state) => state.servers);
|
||||
const instances = useKccStore((state) => state.instances);
|
||||
const openInspector = useKccStore((state) => state.openInspector);
|
||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||
const createIncidentFromAlert = useKccStore((state) => state.createIncidentFromAlert);
|
||||
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
|
||||
const { permissions } = useOperatorRole();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
||||
|
||||
const filteredServers = servers.filter((srv) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
srv.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
srv.ipAddress.includes(search) ||
|
||||
srv.hostname.toLowerCase().includes(search.toLowerCase());
|
||||
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
|
||||
return matchSearch && matchProvider;
|
||||
});
|
||||
|
||||
const handleCreateIncident = (srv: ServerNode) => {
|
||||
openConfirmModal({
|
||||
title: `Создать инцидент для узла: ${srv.name}`,
|
||||
actionName: 'incident.create_from_alert',
|
||||
impactSummary: `Будет открыт критический операторский инцидент, затронутые организации получат оповещение о расследовании дежурной сменой.`,
|
||||
targetType: 'infrastructure',
|
||||
targetId: srv.serverId,
|
||||
targetName: srv.name,
|
||||
onConfirm: async (reason) => {
|
||||
await createIncidentFromAlert(
|
||||
srv.serverId,
|
||||
`Критический сбой узла ${srv.name} (${srv.status.toUpperCase()} alert)`,
|
||||
'critical',
|
||||
reason
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDiagnostics = (srv: ServerNode) => {
|
||||
openConfirmModal({
|
||||
title: `Запрос телеметрии: ${srv.name}`,
|
||||
actionName: 'infrastructure.diagnostics_dump',
|
||||
impactSummary: `Агенту Server Monitor Agent v${srv.agentVersion} будет направлен запрос выгрузки снапшота ресурсов.`,
|
||||
targetType: 'infrastructure',
|
||||
targetId: srv.serverId,
|
||||
targetName: srv.name,
|
||||
onConfirm: async (reason) => {
|
||||
await requestServerDiagnostics(srv.serverId, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-amber-400" />
|
||||
<span>Инфраструктурные узлы (Server Monitor Manager)</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Антикоррупционный адаптер Server Monitor: телеметрия VPS, сертификаты mTLS, бэкапы и инспекция узлов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-400">
|
||||
Всего узлов: <span className="font-bold text-zinc-100">{servers.length}</span> (Selectel:{' '}
|
||||
<span className="text-emerald-400 font-bold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
|
||||
<span className="text-sky-400 font-bold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по имени сервера, IP или хосту..."
|
||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-zinc-500 text-[11px] mr-1">Хостинг:</span>
|
||||
{[
|
||||
{ id: 'all', label: 'Все провайдеры' },
|
||||
{ id: 'Selectel', label: 'Selectel' },
|
||||
{ id: 'Timeweb Cloud', label: 'Timeweb Cloud' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setProviderFilter(tab.id as typeof providerFilter)}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||
providerFilter === tab.id
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servers Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredServers.map((srv) => {
|
||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||
const hasOfflineInstances = srvInstances.some((i) => i.health === 'offline');
|
||||
const isProblematic = srv.status === 'offline' || srv.status === 'critical' || srv.status === 'warning';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={srv.serverId}
|
||||
onClick={() => openInspector('server', srv.serverId)}
|
||||
className={`p-4 rounded-xl border bg-[#091a13] hover:border-zinc-700 cursor-pointer transition-all space-y-3 relative group ${
|
||||
srv.status === 'offline'
|
||||
? 'border-zinc-700 bg-zinc-950/80'
|
||||
: srv.status === 'critical'
|
||||
? 'border-rose-800/70 bg-rose-950/20'
|
||||
: 'border-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-bold text-sm text-zinc-100 group-hover:text-amber-300 transition-colors">
|
||||
{srv.name}
|
||||
</span>
|
||||
<StatusBadge status={srv.status} size="sm" />
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono">
|
||||
{srv.ipAddress} • {srv.hostingProvider} ({srv.region})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider block font-mono">
|
||||
Agent v{srv.agentVersion}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 font-mono">
|
||||
{srv.certificateDaysRemaining}д до серт.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Gauges */}
|
||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-zinc-850 text-xs">
|
||||
{/* CPU */}
|
||||
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
||||
<span>CPU:</span>
|
||||
<span className="font-bold font-mono text-zinc-100">{srv.cpuLoadPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${srv.cpuLoadPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
||||
<span>RAM:</span>
|
||||
<span className="font-bold font-mono text-zinc-100">
|
||||
{Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk */}
|
||||
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
||||
<span>Диск:</span>
|
||||
<span className="font-bold font-mono text-zinc-100">
|
||||
{Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
|
||||
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat timeline */}
|
||||
<div className="pt-1 flex justify-between items-center text-xs">
|
||||
<span className="text-[11px] text-zinc-400">Heartbeat (24h):</span>
|
||||
<HeartbeatTimeline status={srv.status} count={16} />
|
||||
</div>
|
||||
|
||||
{/* Installed modules & Backups status footer */}
|
||||
<div className="pt-2 border-t border-zinc-850 flex items-center justify-between text-[11px] text-zinc-400">
|
||||
<div>
|
||||
Инстансов модулей:{' '}
|
||||
<span className={`font-bold ${hasOfflineInstances ? 'text-rose-400' : 'text-zinc-200'}`}>
|
||||
{srvInstances.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Бэкап:</span>
|
||||
<StatusBadge
|
||||
status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'}
|
||||
size="sm"
|
||||
customLabel={srv.backup.state.toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions (Сценарий 6) */}
|
||||
{isProblematic && (
|
||||
<div className="pt-2 flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canManageIncidents}
|
||||
onClick={() => handleCreateIncident(srv)}
|
||||
className="px-2.5 py-1 rounded-md bg-rose-950/80 hover:bg-rose-900/80 text-rose-300 border border-rose-800/60 text-xs font-semibold flex items-center gap-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<PlusCircle className="w-3.5 h-3.5" />
|
||||
<span>Создать инцидент</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canRequestDiagnostics}
|
||||
onClick={() => handleDiagnostics(srv)}
|
||||
className="px-2.5 py-1 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-700 text-xs font-medium transition-colors disabled:opacity-40"
|
||||
>
|
||||
Запрос диагностики
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
216
src/features/control-center/pages/InstallationsPage.tsx
Normal file
216
src/features/control-center/pages/InstallationsPage.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Smartphone,
|
||||
Search,
|
||||
Monitor,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { InstallationKind, InstallationStatus } from '../contracts';
|
||||
|
||||
export const InstallationsPage: React.FC = () => {
|
||||
const installations = useKccStore((state) => state.installations);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [platformFilter, setPlatformFilter] = useState<'all' | InstallationKind>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | InstallationStatus>('all');
|
||||
|
||||
const filtered = installations.filter((item) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
item.deviceName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.tenantName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.appVersion.includes(search);
|
||||
const matchPlatform = platformFilter === 'all' || item.kind === platformFilter;
|
||||
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
|
||||
return matchSearch && matchPlatform && matchStatus;
|
||||
});
|
||||
|
||||
const outdatedAndroidList = installations.filter(
|
||||
(i) => i.kind === 'android' && (i.status === 'outdated' || i.status === 'blocked')
|
||||
);
|
||||
|
||||
const getPlatformIcon = (kind: InstallationKind) => {
|
||||
switch (kind) {
|
||||
case 'android':
|
||||
return <Smartphone className="w-4 h-4 text-emerald-400" />;
|
||||
case 'desktop':
|
||||
return <Monitor className="w-4 h-4 text-sky-400" />;
|
||||
case 'web':
|
||||
return <Globe className="w-4 h-4 text-amber-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5 text-amber-400" />
|
||||
<span>Установки и клиентские приложения</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Мониторинг клиентских устройств Web, Desktop и Android (ТСД терминалы сбора данных и кассы)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-400">
|
||||
Всего установок: <span className="font-bold text-zinc-100">{installations.length}</span> (найдено:{' '}
|
||||
<span className="font-bold text-amber-400">{filtered.length}</span>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outdated Android Callout (Сценарий 4) */}
|
||||
{outdatedAndroidList.length > 0 && (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-950/20 p-4 flex flex-col md:flex-row items-start md:items-center justify-between gap-3 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-amber-500/20 text-amber-400 border border-amber-500/30 shrink-0 mt-0.5">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-amber-300">
|
||||
Обнаружены устаревшие Android-установки ({outdatedAndroidList.length} устройств)
|
||||
</div>
|
||||
<div className="text-xs text-amber-300/80 mt-0.5">
|
||||
Терминалы сбора данных Zebra, Honeywell и кассы Эвотор работают на устаревших версиях клиента (v1.0.4 – v1.2.0).
|
||||
Рекомендуется запланировать push-обновление APK пакета через MDM канал.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlatformFilter('android');
|
||||
setStatusFilter('outdated');
|
||||
}}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors"
|
||||
>
|
||||
Показать только устаревшие Android
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters & Search */}
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по устройству, организации или версии..."
|
||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform & Status Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-zinc-500 text-[11px] mr-1">Платформа:</span>
|
||||
{[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'android', label: 'Android' },
|
||||
{ id: 'desktop', label: 'Desktop' },
|
||||
{ id: 'web', label: 'Web' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setPlatformFilter(tab.id as typeof platformFilter)}
|
||||
className={`px-2.5 py-1.5 rounded-lg font-medium transition-colors ${
|
||||
platformFilter === tab.id
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 pl-2 border-l border-zinc-800">
|
||||
<span className="text-zinc-500 text-[11px] mr-1">Статус:</span>
|
||||
{[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'outdated', label: 'Устарели' },
|
||||
{ id: 'supported', label: 'В норме' },
|
||||
{ id: 'blocked', label: 'Блок' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
|
||||
className={`px-2.5 py-1.5 rounded-lg font-medium transition-colors ${
|
||||
statusFilter === tab.id
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installations Table */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
||||
<th className="py-3 px-4">Платформа</th>
|
||||
<th className="py-3 px-3">Устройство / Модель</th>
|
||||
<th className="py-3 px-3">Организация (Tenant)</th>
|
||||
<th className="py-3 px-3 font-mono">Версия клиента</th>
|
||||
<th className="py-3 px-3">ОС устройства</th>
|
||||
<th className="py-3 px-3">Статус поддержки</th>
|
||||
<th className="py-3 px-3">Последняя активность</th>
|
||||
<th className="py-3 px-4">Сегмент IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.installationId} className="hover:bg-zinc-800/40 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2 font-medium capitalize text-zinc-200">
|
||||
{getPlatformIcon(item.kind)}
|
||||
<span>{item.kind}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 font-semibold text-zinc-100">{item.deviceName}</td>
|
||||
<td className="py-3 px-3 text-zinc-300 font-medium">{item.tenantName}</td>
|
||||
<td className="py-3 px-3 font-mono font-bold text-amber-300">v{item.appVersion}</td>
|
||||
<td className="py-3 px-3 text-zinc-400 font-mono text-[11px]">{item.osVersion}</td>
|
||||
<td className="py-3 px-3">
|
||||
<StatusBadge status={item.status} size="sm" />
|
||||
</td>
|
||||
<td className="py-3 px-3 text-zinc-400 text-[11px]">
|
||||
{new Date(item.lastSeenAt).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-zinc-500 text-[11px]">{item.ipAddressMasked}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-zinc-500">
|
||||
Установки не найдены по выбранным критериям фильтрации
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
317
src/features/control-center/pages/ModulesPage.tsx
Normal file
317
src/features/control-center/pages/ModulesPage.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Package,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Sliders,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { RolloutStage } from '../contracts';
|
||||
|
||||
export const ModulesPage: React.FC = () => {
|
||||
const modules = useKccStore((state) => state.modules);
|
||||
const rollouts = useKccStore((state) => state.rollouts);
|
||||
const organizations = useKccStore((state) => state.organizations);
|
||||
|
||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||
const triggerRolloutStage = useKccStore((state) => state.triggerRolloutStage);
|
||||
const createRollout = useKccStore((state) => state.createRollout);
|
||||
const { permissions } = useOperatorRole();
|
||||
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string>(modules[0]?.moduleId || 'krona-core');
|
||||
const selectedModule = modules.find((m) => m.moduleId === selectedModuleId);
|
||||
|
||||
// Derived rollout defaults for selected module
|
||||
const rolloutVersion = selectedModule?.latestVersion || '1.0.0';
|
||||
const pilotOrgs = organizations.slice(0, 2).map((o) => o.tenantId);
|
||||
|
||||
const activeRollout = rollouts.find((r) => r.moduleId === selectedModuleId);
|
||||
|
||||
const handleStageChange = (stage: RolloutStage, percentage: number) => {
|
||||
if (!activeRollout) return;
|
||||
openConfirmModal({
|
||||
title: `Смена фазы раскатки: ${activeRollout.moduleName}`,
|
||||
actionName: `rollout.stage_${stage}`,
|
||||
impactSummary: `Конвейер раскатки будет переведен в состояние «${stage}» (${percentage}% трафика).`,
|
||||
targetType: 'rollout',
|
||||
targetId: activeRollout.rolloutId,
|
||||
targetName: activeRollout.moduleName,
|
||||
onConfirm: async (reason) => {
|
||||
await triggerRolloutStage(activeRollout.rolloutId, stage, percentage, reason);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateRollout = () => {
|
||||
if (!selectedModule) return;
|
||||
openConfirmModal({
|
||||
title: `Запуск новой раскатки: ${selectedModule.name}`,
|
||||
actionName: `rollout.start_pilot (${rolloutVersion})`,
|
||||
impactSummary: `Будет запущен пилотный этап обновления до версии ${rolloutVersion} для ${pilotOrgs.length} пилотных организаций.`,
|
||||
targetType: 'rollout',
|
||||
targetId: selectedModule.moduleId,
|
||||
targetName: selectedModule.name,
|
||||
onConfirm: async (reason) => {
|
||||
await createRollout(
|
||||
selectedModule.moduleId,
|
||||
rolloutVersion,
|
||||
'pilot',
|
||||
pilotOrgs,
|
||||
15,
|
||||
'Пилотная фаза раскатки обновления',
|
||||
reason
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-amber-400" />
|
||||
<span>Каталог модулей и Rollout конвейер</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Управление манифестами, compatibility core/schema, пилотными организациями и фазовыми релизами
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-400">
|
||||
Всего модулей: <span className="font-bold text-zinc-100">{modules.length}</span> • Активных раскаток:{' '}
|
||||
<span className="font-bold text-amber-400">{rollouts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid: Module Selector + Module Detail & Rollout Manager */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Left Column: Modules Catalog List */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-3">
|
||||
<div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-1">
|
||||
Каталог системных модулей
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{modules.map((m) => {
|
||||
const isSelected = m.moduleId === selectedModuleId;
|
||||
const hasRollout = rollouts.some((r) => r.moduleId === m.moduleId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.moduleId}
|
||||
onClick={() => setSelectedModuleId(m.moduleId)}
|
||||
className={`p-3 rounded-lg border text-xs cursor-pointer transition-colors space-y-1.5 ${
|
||||
isSelected
|
||||
? 'bg-amber-500/15 border-amber-500/40 text-zinc-100'
|
||||
: 'bg-zinc-950/60 border-zinc-800 text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-zinc-100">{m.name}</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded-xs bg-zinc-900 border border-zinc-700 text-amber-300">
|
||||
v{m.latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 line-clamp-1">{m.description}</p>
|
||||
<div className="flex justify-between items-center text-[10px] text-zinc-500 pt-1">
|
||||
<span>Тенантов: {m.activeTenantsCount} • Инстансов: {m.instancesCount}</span>
|
||||
{hasRollout && (
|
||||
<span className="px-1.5 py-0.5 rounded-xs bg-indigo-950 text-indigo-300 border border-indigo-700 font-bold uppercase">
|
||||
Rollout
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right 2 Columns: Module Details & Rollout Controller */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
{selectedModule && (
|
||||
<>
|
||||
{/* Module Header Card */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs text-amber-400 font-bold">{selectedModule.code}</span>
|
||||
<span className="text-zinc-600">•</span>
|
||||
<span className="text-xs text-zinc-400">Категория: {selectedModule.category}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{selectedModule.name}</h2>
|
||||
<p className="text-xs text-zinc-300 mt-1">{selectedModule.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="text-xs text-zinc-400">Текущая стабильная версия:</span>
|
||||
<div className="font-mono font-bold text-lg text-emerald-400">v{selectedModule.latestVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs pt-3 border-t border-zinc-800">
|
||||
<div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
|
||||
<div className="text-zinc-400 mb-0.5">Канал релиза:</div>
|
||||
<div className="font-semibold text-zinc-200 uppercase">{selectedModule.rolloutChannel}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
|
||||
<div className="text-zinc-400 mb-0.5">Манифест:</div>
|
||||
<div className="font-semibold text-zinc-200 font-mono">v{selectedModule.manifestVersion}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
|
||||
<div className="text-zinc-400 mb-0.5">Зависимости:</div>
|
||||
<div className="font-semibold text-zinc-200">
|
||||
{selectedModule.dependencies.length > 0 ? selectedModule.dependencies.join(', ') : 'Нет (Базовый)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rollout Control Panel (Сценарий 8) */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-5 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4 text-amber-400" />
|
||||
<h3 className="text-sm font-bold text-zinc-200">Управление раскаткой (Rollout Pipeline)</h3>
|
||||
</div>
|
||||
{activeRollout && <StatusBadge status={activeRollout.stage} size="sm" />}
|
||||
</div>
|
||||
|
||||
{activeRollout ? (
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* Progress visualizer */}
|
||||
<div className="p-4 rounded-xl bg-zinc-950 border border-zinc-800 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-200">
|
||||
Целевая версия: <span className="font-mono text-amber-300">v{activeRollout.targetVersion}</span>
|
||||
</span>
|
||||
<span className="font-bold text-amber-400 font-mono text-sm">{activeRollout.percentage}%</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-zinc-800 h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-400 transition-all duration-300"
|
||||
style={{ width: `${activeRollout.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
||||
<span>Стадия: <strong className="text-zinc-200 uppercase">{activeRollout.stage}</strong></span>
|
||||
<span>Пилотных организаций: {activeRollout.pilotTenantIds.length}</span>
|
||||
<span>Старт: {new Date(activeRollout.startedAt).toLocaleDateString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rollout Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'pilot'}
|
||||
onClick={() => handleStageChange('pilot', 15)}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs font-medium disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Фаза 1: Pilot (15%)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'percentage'}
|
||||
onClick={() => handleStageChange('percentage', 50)}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs font-medium disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Фаза 2: 50% организаций
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'general_availability'}
|
||||
onClick={() => handleStageChange('general_availability', 100)}
|
||||
className="px-3 py-1.5 rounded-lg bg-emerald-950 text-emerald-300 border border-emerald-700 hover:bg-emerald-900 text-xs font-bold disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Фаза 3: Полный релиз (100% GA)
|
||||
</button>
|
||||
|
||||
{activeRollout.stage !== 'paused' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canPauseRollbackRollouts}
|
||||
onClick={() => handleStageChange('paused', activeRollout.percentage)}
|
||||
className="px-3 py-1.5 rounded-lg bg-amber-950/70 text-amber-300 border border-amber-700 hover:bg-amber-900/60 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
<span>Поставить на паузу</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canTriggerRollouts}
|
||||
onClick={() => handleStageChange('pilot', activeRollout.percentage)}
|
||||
className="px-3 py-1.5 rounded-lg bg-amber-500 text-zinc-950 font-bold text-xs hover:bg-amber-400 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
<span>Снять с паузы</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canPauseRollbackRollouts || activeRollout.stage === 'rolled_back'}
|
||||
onClick={() => handleStageChange('rolled_back', 0)}
|
||||
className="px-3 py-1.5 rounded-lg bg-rose-950 text-rose-300 border border-rose-700 hover:bg-rose-900 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Rollback (Откат)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-zinc-950 border border-zinc-800 text-xs space-y-3">
|
||||
<p className="text-zinc-400">
|
||||
Для модуля <strong className="text-zinc-200">{selectedModule.name}</strong> нет активных раскаток.
|
||||
Вы можете запустить пилотный релиз новой версии.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canTriggerRollouts}
|
||||
onClick={handleCreateRollout}
|
||||
className="px-4 py-2 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
<span>Запустить пилотный Rollout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Release History & Deployments */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-3 text-xs">
|
||||
<h3 className="text-sm font-bold text-zinc-200">История релизов и совместимость (Compatibility)</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedModule.releaseHistory.map((rel) => (
|
||||
<div key={rel.version} className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-amber-300 font-mono">v{rel.version}</span>
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
{new Date(rel.releasedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-zinc-300 text-[11px]">{rel.changelog}</p>
|
||||
<div className="text-[10px] text-zinc-400 font-mono pt-1">
|
||||
Совместимость с Core: {rel.compatibleCore}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
176
src/features/control-center/pages/OrganizationsPage.tsx
Normal file
176
src/features/control-center/pages/OrganizationsPage.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Search,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { PlatformStatus } from '../contracts';
|
||||
|
||||
export const OrganizationsPage: React.FC = () => {
|
||||
const organizations = useKccStore((state) => state.organizations);
|
||||
const openInspector = useKccStore((state) => state.openInspector);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | PlatformStatus>('all');
|
||||
|
||||
const filtered = organizations.filter((org) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
org.displayName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
org.inn.includes(search) ||
|
||||
org.domain.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = statusFilter === 'all' || org.platformStatus === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-amber-400" />
|
||||
<span>Организации платформы (Tenants)</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Реестр клиентских организаций, управление статусами, тарифами и 360-анализ тенантов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-400">
|
||||
Всего организаций: <span className="font-bold text-zinc-100">{organizations.length}</span> (найдено:{' '}
|
||||
<span className="font-bold text-amber-400">{filtered.length}</span>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по названию, ИНН или домену..."
|
||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filters */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 text-xs">
|
||||
{[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'trial', label: 'Trial' },
|
||||
{ id: 'grace', label: 'Grace' },
|
||||
{ id: 'suspended', label: 'Suspended' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||||
statusFilter === tab.id
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
|
||||
: 'bg-zinc-900/60 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations Table */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
||||
<th className="py-3 px-4">Организация / ИНН</th>
|
||||
<th className="py-3 px-3">Статус</th>
|
||||
<th className="py-3 px-3">Тарифный план</th>
|
||||
<th className="py-3 px-3 text-center">Польз. / Точки</th>
|
||||
<th className="py-3 px-3 text-center">Модули</th>
|
||||
<th className="py-3 px-3 text-right">MRR Доход</th>
|
||||
<th className="py-3 px-3 text-right">Задолженность</th>
|
||||
<th className="py-3 px-3">Активность</th>
|
||||
<th className="py-3 px-4 text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{filtered.map((org) => (
|
||||
<tr
|
||||
key={org.tenantId}
|
||||
onClick={() => openInspector('organization', org.tenantId)}
|
||||
className="hover:bg-zinc-800/40 cursor-pointer transition-colors group"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-bold text-zinc-100 group-hover:text-amber-300 transition-colors">
|
||||
{org.displayName}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
||||
ИНН {org.inn} • {org.domain}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<StatusBadge status={org.platformStatus} size="sm" />
|
||||
</td>
|
||||
<td className="py-3 px-3 font-medium text-zinc-300">{org.tariffPlan}</td>
|
||||
<td className="py-3 px-3 text-center tabular-nums">
|
||||
<span className="font-bold text-zinc-100">{org.userCount}</span>
|
||||
<span className="text-zinc-500 text-[10px]"> / {org.branchCount}</span>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-bold text-zinc-200 tabular-nums">
|
||||
{org.installedModulesCount}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-amber-400 font-bold" />
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
<MoneyDisplay
|
||||
minorUnits={org.outstandingDebtMinorUnits}
|
||||
className={org.outstandingDebtMinorUnits > 0 ? 'text-rose-400 font-bold' : 'text-zinc-400'}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-zinc-400 text-[11px]">
|
||||
{new Date(org.lastActiveAt).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInspector('organization', org.tenantId);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-zinc-900 border border-zinc-700 text-zinc-300 hover:text-amber-300 hover:border-amber-500/40 text-[11px] font-medium transition-colors"
|
||||
>
|
||||
<span>360° Карточка</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-8 text-center text-zinc-500">
|
||||
Организации не найдены по заданным критериям фильтрации
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
385
src/features/control-center/pages/OverviewPage.tsx
Normal file
385
src/features/control-center/pages/OverviewPage.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
CreditCard,
|
||||
Smartphone,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
ArrowUpRight,
|
||||
Activity,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
||||
|
||||
export const OverviewPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { role, operator } = useOperatorRole();
|
||||
|
||||
const organizations = useKccStore((state) => state.organizations);
|
||||
const subscriptions = useKccStore((state) => state.subscriptions);
|
||||
const installations = useKccStore((state) => state.installations);
|
||||
const instances = useKccStore((state) => state.instances);
|
||||
const servers = useKccStore((state) => state.servers);
|
||||
const incidents = useKccStore((state) => state.incidents);
|
||||
const openInspector = useKccStore((state) => state.openInspector);
|
||||
|
||||
// Aggregates
|
||||
const totalOrgs = organizations.length;
|
||||
const activeOrgs = organizations.filter((o) => o.platformStatus === 'active').length;
|
||||
const trialOrgs = organizations.filter((o) => o.platformStatus === 'trial').length;
|
||||
const graceOrgs = organizations.filter((o) => o.platformStatus === 'grace').length;
|
||||
const suspendedOrgs = organizations.filter((o) => o.platformStatus === 'suspended').length;
|
||||
|
||||
const totalUsers = organizations.reduce((sum, o) => sum + o.userCount, 0);
|
||||
const totalBranches = organizations.reduce((sum, o) => sum + o.branchCount, 0);
|
||||
const totalMrrMinorUnits = organizations.reduce((sum, o) => sum + o.mrrMinorUnits, 0);
|
||||
|
||||
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
||||
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
|
||||
|
||||
const webInstalls = installations.filter((i) => i.kind === 'web').length;
|
||||
const desktopInstalls = installations.filter((i) => i.kind === 'desktop').length;
|
||||
const androidInstalls = installations.filter((i) => i.kind === 'android').length;
|
||||
const outdatedAndroid = installations.filter((i) => i.kind === 'android' && i.status === 'outdated').length;
|
||||
|
||||
const instancesOffline = instances.filter((i) => i.health === 'offline');
|
||||
const instancesDegraded = instances.filter((i) => i.health === 'degraded');
|
||||
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
|
||||
|
||||
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
||||
const serversWarning = servers.filter((s) => s.status === 'warning').length;
|
||||
const serversCritical = servers.filter((s) => s.status === 'critical').length;
|
||||
const serversOffline = servers.filter((s) => s.status === 'offline').length;
|
||||
|
||||
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
|
||||
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Headline */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
||||
<span>Сводная операторская панель</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-400 border border-emerald-700/60 font-mono">
|
||||
Production Scope
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">
|
||||
Оперативный контроль тенантов, подписок, модульных инстансов и инфраструктурных узлов Krona OS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-zinc-400">Роль оператора:</span>
|
||||
<span className="font-semibold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md border border-amber-500/20">
|
||||
{operator.name} ({role})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Row KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{/* Orgs */}
|
||||
<div
|
||||
onClick={() => navigate('/control/organizations')}
|
||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
||||
<span>Организации</span>
|
||||
<Building2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{totalOrgs}</div>
|
||||
<div className="flex gap-1.5 text-[10px] text-zinc-400 flex-wrap">
|
||||
<span className="text-emerald-400 font-semibold">{activeOrgs} act</span>
|
||||
<span>•</span>
|
||||
<span className="text-amber-400">{trialOrgs} trial</span>
|
||||
<span>•</span>
|
||||
<span className="text-orange-400">{graceOrgs} grace</span>
|
||||
<span>•</span>
|
||||
<span className="text-rose-400">{suspendedOrgs} susp</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users & Branches */}
|
||||
<div className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 space-y-2">
|
||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
||||
<span>Пользователи / Точки</span>
|
||||
<Users className="w-4 h-4 text-sky-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{totalUsers}</div>
|
||||
<div className="text-[10px] text-zinc-400">{totalBranches} филиалов в сети</div>
|
||||
</div>
|
||||
|
||||
{/* MRR Revenue */}
|
||||
<div
|
||||
onClick={() => navigate('/control/billing')}
|
||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
||||
<span>MRR Доход</span>
|
||||
<CreditCard className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="text-xl font-black text-amber-400">
|
||||
<MoneyDisplay minorUnits={totalMrrMinorUnits} />
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400">
|
||||
Истекают: <span className="text-amber-300 font-semibold">{expiring7d.length} за 7д</span> / {expiring30d.length} за 30д
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installations */}
|
||||
<div
|
||||
onClick={() => navigate('/control/installations')}
|
||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
||||
<span>Установки ПО</span>
|
||||
<Smartphone className="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">
|
||||
{installations.length}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-400">
|
||||
Web: {webInstalls} • Desk: {desktopInstalls} • <span className="text-amber-400 font-semibold">Andr: {androidInstalls}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Health */}
|
||||
<div
|
||||
onClick={() => navigate('/control/modules')}
|
||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
||||
<span>Инстансы модулей</span>
|
||||
<Activity className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{instances.length}</div>
|
||||
<div className="text-[10px] text-zinc-400">
|
||||
<span className="text-emerald-400 font-semibold">{instancesHealthy.length} ok</span> •{' '}
|
||||
<span className="text-amber-400">{instancesDegraded.length} degr</span> •{' '}
|
||||
<span className="text-rose-400 font-bold">{instancesOffline.length} off</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VPS Servers */}
|
||||
<div
|
||||
onClick={() => navigate('/control/infrastructure')}
|
||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
||||
<span>Серверы & VPS</span>
|
||||
<Server className="w-4 h-4 text-teal-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{servers.length}</div>
|
||||
<div className="text-[10px] text-zinc-400">
|
||||
<span className="text-emerald-400 font-semibold">{serversHealthy} ok</span> •{' '}
|
||||
<span className="text-amber-400">{serversWarning} warn</span> •{' '}
|
||||
<span className="text-rose-400 font-bold">{serversCritical + serversOffline} crit/off</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Central Strip & Operator Queue */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Central 2 Columns: Infrastructure & Modules Health Matrix */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Section: Infrastructure Status Strip */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-amber-400" />
|
||||
<h3 className="text-sm font-bold text-zinc-200">
|
||||
Узлы серверной инфраструктуры (Server Monitor Live)
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/control/infrastructure')}
|
||||
className="text-xs text-amber-400 hover:text-amber-300 font-medium flex items-center gap-1"
|
||||
>
|
||||
<span>Все серверы ({servers.length})</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{servers.slice(0, 5).map((srv) => (
|
||||
<div
|
||||
key={srv.serverId}
|
||||
onClick={() => openInspector('server', srv.serverId)}
|
||||
className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80 hover:border-zinc-700 cursor-pointer transition-colors flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-zinc-100">{srv.name}</span>
|
||||
<StatusBadge status={srv.status} size="sm" />
|
||||
<span className="text-[10px] text-zinc-400 font-mono">
|
||||
{srv.hostingProvider} • {srv.region}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-zinc-400 font-mono text-[11px]">
|
||||
IP: {srv.ipAddress} • CPU: {srv.cpuLoadPercent}% • RAM:{' '}
|
||||
{Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}% • Диск:{' '}
|
||||
{Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="space-y-1 text-right">
|
||||
<div className="text-[10px] text-zinc-400">Heartbeat (24h)</div>
|
||||
<HeartbeatTimeline status={srv.status} count={16} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section: Offline & Degraded Module Instances Alert Lane */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-rose-400" />
|
||||
<h3 className="text-sm font-bold text-zinc-200">
|
||||
Модули, требующие вмешательства (Degraded / Offline)
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-400">
|
||||
{instancesOffline.length + instancesDegraded.length} инстансов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[...instancesOffline, ...instancesDegraded].map((inst) => (
|
||||
<div
|
||||
key={inst.moduleInstanceId}
|
||||
onClick={() => openInspector('server', inst.serverId)}
|
||||
className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800/90 hover:border-zinc-700 cursor-pointer transition-colors flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{inst.moduleName}</span>
|
||||
<StatusBadge status={inst.health} size="sm" />
|
||||
<span className="text-[10px] text-zinc-400 font-mono">v{inst.currentVersion}</span>
|
||||
</div>
|
||||
<div className="text-zinc-400 text-[11px]">
|
||||
Тенант: <span className="text-zinc-200">{inst.tenantName}</span> • Узел:{' '}
|
||||
<span className="text-amber-400/90 font-mono">{inst.serverName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
Last HB: {new Date(inst.lastHeartbeatAt).toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{instancesOffline.length + instancesDegraded.length === 0 && (
|
||||
<div className="p-4 rounded-lg bg-zinc-950/40 text-center text-xs text-zinc-500">
|
||||
Все экземпляры модулей функционируют в штатном режиме
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Operator Action Queue (Очередь операторских действий) */}
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-amber-500/30 bg-[#0a1c15] p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
||||
<h3 className="text-sm font-bold text-amber-300">Очередь внимания (Action Queue)</h3>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-mono font-bold">
|
||||
{openIncidents.length + expiring7d.length + outdatedAndroid} событий
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
{/* Critical Incidents */}
|
||||
{openIncidents.map((inc) => (
|
||||
<div
|
||||
key={inc.incidentId}
|
||||
onClick={() => openInspector('incident', inc.incidentId)}
|
||||
className="p-3 rounded-lg bg-rose-950/40 border border-rose-800/50 hover:border-rose-700 cursor-pointer transition-colors space-y-1.5"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono font-bold text-rose-300">{inc.incidentNumber}</span>
|
||||
<StatusBadge status={inc.status} size="sm" />
|
||||
</div>
|
||||
<p className="font-semibold text-zinc-100">{inc.summary}</p>
|
||||
<div className="text-[11px] text-zinc-400 flex justify-between">
|
||||
<span>Severity: {inc.severity.toUpperCase()}</span>
|
||||
<span>{new Date(inc.openedAt).toLocaleTimeString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Expiring Subscriptions (7 days) */}
|
||||
{expiring7d.map((sub) => (
|
||||
<div
|
||||
key={sub.subscriptionId}
|
||||
onClick={() => navigate('/control/billing')}
|
||||
className="p-3 rounded-lg bg-amber-950/30 border border-amber-700/40 hover:border-amber-600 cursor-pointer transition-colors space-y-1.5"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-amber-200">{sub.tenantName}</span>
|
||||
<StatusBadge status="grace" customLabel="7 дней" size="sm" />
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
||||
<span>Сумма тарифа:</span>
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
Окончание: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Outdated Android Terminals */}
|
||||
{outdatedAndroid > 0 && (
|
||||
<div
|
||||
onClick={() => navigate('/control/installations')}
|
||||
className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-1.5"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-200">Устаревшие ТСД Android</span>
|
||||
<StatusBadge status="outdated" size="sm" />
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Обнаружено {outdatedAndroid} терминалов Zebra/Honeywell с устаревшей версией приложения.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed Backups */}
|
||||
{failedBackups.map((srv) => (
|
||||
<div
|
||||
key={srv.serverId}
|
||||
onClick={() => openInspector('server', srv.serverId)}
|
||||
className="p-3 rounded-lg bg-rose-950/30 border border-rose-800/40 hover:border-rose-700 cursor-pointer transition-colors space-y-1.5"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-rose-300">Сбой резервного копирования</span>
|
||||
<StatusBadge status="critical" size="sm" customLabel="BACKUP FAIL" />
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-300">
|
||||
Узел {srv.name}: No space left on device при ночной ротации.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
src/features/control-center/pages/index.ts
Normal file
11
src/features/control-center/pages/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export * from './OverviewPage';
|
||||
export * from './OrganizationsPage';
|
||||
export * from './BillingPage';
|
||||
export * from './ModulesPage';
|
||||
export * from './InstallationsPage';
|
||||
export * from './InfrastructurePage';
|
||||
export * from './AnnouncementsPage';
|
||||
export * from './IncidentsPage';
|
||||
export * from './AuditPage';
|
||||
export * from './ControlForbiddenPage';
|
||||
export * from './ControlAccessGatePage';
|
||||
201
src/features/control-center/store/db.ts
Normal file
201
src/features/control-center/store/db.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { openDB, IDBPDatabase } from 'idb';
|
||||
import type {
|
||||
OrganizationProjection,
|
||||
ModuleDefinition,
|
||||
Subscription,
|
||||
Installation,
|
||||
ModuleInstance,
|
||||
ServerNode,
|
||||
Incident,
|
||||
Announcement,
|
||||
OperatorAuditEvent,
|
||||
Rollout,
|
||||
} from '../contracts';
|
||||
import {
|
||||
SEED_ORGANIZATIONS,
|
||||
SEED_MODULES,
|
||||
SEED_SUBSCRIPTIONS,
|
||||
SEED_INSTALLATIONS,
|
||||
SEED_MODULE_INSTANCES,
|
||||
SEED_SERVER_NODES,
|
||||
SEED_INCIDENTS,
|
||||
SEED_ANNOUNCEMENTS,
|
||||
SEED_ROLLOUTS,
|
||||
SEED_AUDIT_LOG,
|
||||
} from '../fixtures/seedData';
|
||||
|
||||
export const KCC_DB_NAME = 'krona_control_center_db';
|
||||
export const KCC_DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||
|
||||
export async function getKccDb(): Promise<IDBPDatabase> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB(KCC_DB_NAME, KCC_DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('organizations')) {
|
||||
db.createObjectStore('organizations', { keyPath: 'tenantId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('modules')) {
|
||||
db.createObjectStore('modules', { keyPath: 'moduleId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('subscriptions')) {
|
||||
db.createObjectStore('subscriptions', { keyPath: 'subscriptionId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('installations')) {
|
||||
db.createObjectStore('installations', { keyPath: 'installationId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('instances')) {
|
||||
db.createObjectStore('instances', { keyPath: 'moduleInstanceId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('servers')) {
|
||||
db.createObjectStore('servers', { keyPath: 'serverId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('incidents')) {
|
||||
db.createObjectStore('incidents', { keyPath: 'incidentId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('announcements')) {
|
||||
db.createObjectStore('announcements', { keyPath: 'announcementId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('rollouts')) {
|
||||
db.createObjectStore('rollouts', { keyPath: 'rolloutId' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('audit_events')) {
|
||||
const auditStore = db.createObjectStore('audit_events', { keyPath: 'eventId' });
|
||||
auditStore.createIndex('by_occurred', 'occurredAt');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export interface KccStateData {
|
||||
organizations: OrganizationProjection[];
|
||||
modules: ModuleDefinition[];
|
||||
subscriptions: Subscription[];
|
||||
installations: Installation[];
|
||||
instances: ModuleInstance[];
|
||||
servers: ServerNode[];
|
||||
incidents: Incident[];
|
||||
announcements: Announcement[];
|
||||
rollouts: Rollout[];
|
||||
auditLog: OperatorAuditEvent[];
|
||||
}
|
||||
|
||||
export async function initKccStorage(forceReset = false): Promise<KccStateData> {
|
||||
const db = await getKccDb();
|
||||
|
||||
const orgCount = await db.count('organizations');
|
||||
if (orgCount === 0 || forceReset) {
|
||||
const tx = db.transaction(
|
||||
[
|
||||
'organizations',
|
||||
'modules',
|
||||
'subscriptions',
|
||||
'installations',
|
||||
'instances',
|
||||
'servers',
|
||||
'incidents',
|
||||
'announcements',
|
||||
'rollouts',
|
||||
'audit_events',
|
||||
],
|
||||
'readwrite'
|
||||
);
|
||||
|
||||
// Clear existing if forceReset
|
||||
if (forceReset) {
|
||||
await Promise.all([
|
||||
tx.objectStore('organizations').clear(),
|
||||
tx.objectStore('modules').clear(),
|
||||
tx.objectStore('subscriptions').clear(),
|
||||
tx.objectStore('installations').clear(),
|
||||
tx.objectStore('instances').clear(),
|
||||
tx.objectStore('servers').clear(),
|
||||
tx.objectStore('incidents').clear(),
|
||||
tx.objectStore('announcements').clear(),
|
||||
tx.objectStore('rollouts').clear(),
|
||||
tx.objectStore('audit_events').clear(),
|
||||
]);
|
||||
}
|
||||
|
||||
for (const item of SEED_ORGANIZATIONS) await tx.objectStore('organizations').put(item);
|
||||
for (const item of SEED_MODULES) await tx.objectStore('modules').put(item);
|
||||
for (const item of SEED_SUBSCRIPTIONS) await tx.objectStore('subscriptions').put(item);
|
||||
for (const item of SEED_INSTALLATIONS) await tx.objectStore('installations').put(item);
|
||||
for (const item of SEED_MODULE_INSTANCES) await tx.objectStore('instances').put(item);
|
||||
for (const item of SEED_SERVER_NODES) await tx.objectStore('servers').put(item);
|
||||
for (const item of SEED_INCIDENTS) await tx.objectStore('incidents').put(item);
|
||||
for (const item of SEED_ANNOUNCEMENTS) await tx.objectStore('announcements').put(item);
|
||||
for (const item of SEED_ROLLOUTS) await tx.objectStore('rollouts').put(item);
|
||||
for (const item of SEED_AUDIT_LOG) await tx.objectStore('audit_events').put(item);
|
||||
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
// Load current dataset
|
||||
const [
|
||||
organizations,
|
||||
modules,
|
||||
subscriptions,
|
||||
installations,
|
||||
instances,
|
||||
servers,
|
||||
incidents,
|
||||
announcements,
|
||||
rollouts,
|
||||
auditLog,
|
||||
] = await Promise.all([
|
||||
db.getAll('organizations') as Promise<OrganizationProjection[]>,
|
||||
db.getAll('modules') as Promise<ModuleDefinition[]>,
|
||||
db.getAll('subscriptions') as Promise<Subscription[]>,
|
||||
db.getAll('installations') as Promise<Installation[]>,
|
||||
db.getAll('instances') as Promise<ModuleInstance[]>,
|
||||
db.getAll('servers') as Promise<ServerNode[]>,
|
||||
db.getAll('incidents') as Promise<Incident[]>,
|
||||
db.getAll('announcements') as Promise<Announcement[]>,
|
||||
db.getAll('rollouts') as Promise<Rollout[]>,
|
||||
db.getAll('audit_events') as Promise<OperatorAuditEvent[]>,
|
||||
]);
|
||||
|
||||
// Sort auditLog descending by occurredAt
|
||||
auditLog.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
|
||||
|
||||
return {
|
||||
organizations,
|
||||
modules,
|
||||
subscriptions,
|
||||
installations,
|
||||
instances,
|
||||
servers,
|
||||
incidents,
|
||||
announcements,
|
||||
rollouts,
|
||||
auditLog,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistItem<T>(storeName: string, item: T): Promise<void> {
|
||||
const db = await getKccDb();
|
||||
await db.put(storeName, item);
|
||||
}
|
||||
|
||||
// Simple SHA-256 for browser / node environment hash-chaining
|
||||
export async function computeSha256(message: string): Promise<string> {
|
||||
if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
|
||||
const msgBuffer = new TextEncoder().encode(message);
|
||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Deterministic fallback for environments without subtle crypto
|
||||
let hash = 0;
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
const char = message.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash |= 0;
|
||||
}
|
||||
const hex = Math.abs(hash).toString(16).padStart(8, '0');
|
||||
return `fallback_sha256_${hex}_${message.length}`;
|
||||
}
|
||||
643
src/features/control-center/store/kccStore.ts
Normal file
643
src/features/control-center/store/kccStore.ts
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
import { create } from 'zustand';
|
||||
import type {
|
||||
OperatorRole,
|
||||
OperatorProfile,
|
||||
OrganizationProjection,
|
||||
ModuleDefinition,
|
||||
Subscription,
|
||||
Installation,
|
||||
ModuleInstance,
|
||||
ServerNode,
|
||||
Incident,
|
||||
Announcement,
|
||||
Rollout,
|
||||
OperatorAuditEvent,
|
||||
PlatformStatus,
|
||||
RolloutStage,
|
||||
} from '../contracts';
|
||||
import { OPERATOR_ROLE_DEFINITIONS } from '../contracts';
|
||||
import { initKccStorage, persistItem, computeSha256 } from './db';
|
||||
|
||||
export interface InspectorState {
|
||||
type: 'organization' | 'server' | 'incident' | 'module' | 'installation';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ConfirmActionModalConfig {
|
||||
title: string;
|
||||
actionName: string;
|
||||
impactSummary: string;
|
||||
targetType: OperatorAuditEvent['target']['type'];
|
||||
targetId: string;
|
||||
targetName: string;
|
||||
onConfirm: (reason: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface KccStoreState {
|
||||
// Session & RBAC
|
||||
activeRole: OperatorRole;
|
||||
activeOperator: OperatorProfile;
|
||||
isAccessGatePassed: boolean;
|
||||
|
||||
// Data
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
serverMonitorOutage: boolean; // Mock-состояние «Server Monitor недоступен»
|
||||
organizations: OrganizationProjection[];
|
||||
modules: ModuleDefinition[];
|
||||
subscriptions: Subscription[];
|
||||
installations: Installation[];
|
||||
instances: ModuleInstance[];
|
||||
servers: ServerNode[];
|
||||
incidents: Incident[];
|
||||
announcements: Announcement[];
|
||||
rollouts: Rollout[];
|
||||
auditLog: OperatorAuditEvent[];
|
||||
|
||||
// Inspector & Action Modal
|
||||
inspectorTarget: InspectorState | null;
|
||||
pendingConfirmModal: ConfirmActionModalConfig | null;
|
||||
|
||||
// Actions
|
||||
initialize: (forceReset?: boolean) => Promise<void>;
|
||||
setActiveRole: (role: OperatorRole) => void;
|
||||
setAccessGatePassed: (passed: boolean) => void;
|
||||
openInspector: (type: InspectorState['type'], id: string) => void;
|
||||
closeInspector: () => void;
|
||||
setServerMonitorOutage: (outage: boolean) => void;
|
||||
toggleServerMonitorOutage: () => void;
|
||||
|
||||
openConfirmModal: (config: ConfirmActionModalConfig) => void;
|
||||
closeConfirmModal: () => void;
|
||||
|
||||
// Mutations with audit recording
|
||||
recordAuditEvent: (params: {
|
||||
action: string;
|
||||
target: OperatorAuditEvent['target'];
|
||||
reason: string;
|
||||
impactSummary: string;
|
||||
}) => Promise<OperatorAuditEvent>;
|
||||
|
||||
updateOrganizationStatus: (tenantId: string, status: PlatformStatus, reason: string) => Promise<void>;
|
||||
grantModuleTrial: (
|
||||
tenantId: string,
|
||||
moduleId: string,
|
||||
durationDays: number,
|
||||
reason: string
|
||||
) => Promise<void>;
|
||||
grantGracePeriod: (subscriptionId: string, days: number, reason: string) => Promise<void>;
|
||||
renewSubscription: (
|
||||
subscriptionId: string,
|
||||
newPeriod: 'month' | 'year',
|
||||
activeModules: string[],
|
||||
reason: string
|
||||
) => Promise<void>;
|
||||
createIncidentFromAlert: (
|
||||
serverId: string,
|
||||
summary: string,
|
||||
severity: Incident['severity'],
|
||||
reason: string
|
||||
) => Promise<void>;
|
||||
resolveIncident: (incidentId: string, reason: string) => Promise<void>;
|
||||
createAnnouncement: (
|
||||
announcement: Omit<Announcement, 'announcementId' | 'createdAt' | 'author' | 'stats'>,
|
||||
reason: string
|
||||
) => Promise<void>;
|
||||
triggerRolloutStage: (rolloutId: string, stage: RolloutStage, percentage: number, reason: string) => Promise<void>;
|
||||
createRollout: (
|
||||
moduleId: string,
|
||||
version: string,
|
||||
stage: RolloutStage,
|
||||
pilotTenantIds: string[],
|
||||
percentage: number,
|
||||
notes: string,
|
||||
reason: string
|
||||
) => Promise<void>;
|
||||
requestServerDiagnostics: (serverId: string, reason: string) => Promise<void>;
|
||||
scheduleServerMaintenance: (serverId: string, reason: string) => Promise<void>;
|
||||
resetToDefaults: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_OPERATORS: Record<OperatorRole, OperatorProfile> = {
|
||||
platform_owner: {
|
||||
id: 'op-001',
|
||||
name: 'Михаил Романов',
|
||||
role: 'platform_owner',
|
||||
email: 'm.romanov@krona.systems',
|
||||
title: 'Владелец платформы & Архитектор',
|
||||
},
|
||||
platform_admin: {
|
||||
id: 'op-002',
|
||||
name: 'Алексей Смирнов',
|
||||
role: 'platform_admin',
|
||||
email: 'a.smirnov@krona.systems',
|
||||
title: 'Главный системный администратор',
|
||||
},
|
||||
support_operator: {
|
||||
id: 'op-003',
|
||||
name: 'Елена Соколова',
|
||||
role: 'support_operator',
|
||||
email: 'e.sokolova@krona.systems',
|
||||
title: 'Ведущий инженер поддержки L2',
|
||||
},
|
||||
billing_manager: {
|
||||
id: 'op-004',
|
||||
name: 'Анна Кузнецова',
|
||||
role: 'billing_manager',
|
||||
email: 'a.kuznetsova@krona.systems',
|
||||
title: 'Руководитель биллинговых операций',
|
||||
},
|
||||
security_auditor: {
|
||||
id: 'op-005',
|
||||
name: 'Сергей Белов',
|
||||
role: 'security_auditor',
|
||||
email: 's.belov@krona.systems',
|
||||
title: 'Офицер информационной безопасности',
|
||||
},
|
||||
release_manager: {
|
||||
id: 'op-006',
|
||||
name: 'Мария Лебедева',
|
||||
role: 'release_manager',
|
||||
email: 'm.lebedeva@krona.systems',
|
||||
title: 'Руководитель релизного конвейера',
|
||||
},
|
||||
};
|
||||
|
||||
export const useKccStore = create<KccStoreState>((set, get) => ({
|
||||
activeRole: 'platform_owner',
|
||||
activeOperator: DEFAULT_OPERATORS.platform_owner,
|
||||
isAccessGatePassed: typeof window !== 'undefined' ? localStorage.getItem('kcc_access_gate') === '1' : false,
|
||||
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
serverMonitorOutage: false,
|
||||
|
||||
organizations: [],
|
||||
modules: [],
|
||||
subscriptions: [],
|
||||
installations: [],
|
||||
instances: [],
|
||||
servers: [],
|
||||
incidents: [],
|
||||
announcements: [],
|
||||
rollouts: [],
|
||||
auditLog: [],
|
||||
|
||||
inspectorTarget: null,
|
||||
pendingConfirmModal: null,
|
||||
|
||||
initialize: async (forceReset = false) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const data = await initKccStorage(forceReset);
|
||||
set({
|
||||
...data,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[KCC DB] Failed to init indexedDB:', err);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setActiveRole: (role) => {
|
||||
set({
|
||||
activeRole: role,
|
||||
activeOperator: DEFAULT_OPERATORS[role],
|
||||
});
|
||||
},
|
||||
|
||||
setAccessGatePassed: (passed) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (passed) localStorage.setItem('kcc_access_gate', '1');
|
||||
else localStorage.removeItem('kcc_access_gate');
|
||||
}
|
||||
set({ isAccessGatePassed: passed });
|
||||
},
|
||||
|
||||
openInspector: (type, id) => set({ inspectorTarget: { type, id } }),
|
||||
closeInspector: () => set({ inspectorTarget: null }),
|
||||
|
||||
setServerMonitorOutage: (outage) => set({ serverMonitorOutage: outage }),
|
||||
toggleServerMonitorOutage: () => set((state) => ({ serverMonitorOutage: !state.serverMonitorOutage })),
|
||||
|
||||
openConfirmModal: (config) => set({ pendingConfirmModal: config }),
|
||||
closeConfirmModal: () => set({ pendingConfirmModal: null }),
|
||||
|
||||
recordAuditEvent: async ({ action, target, reason, impactSummary }) => {
|
||||
const { activeOperator, auditLog } = get();
|
||||
const prevEvent = auditLog[0];
|
||||
const previousHash = prevEvent ? prevEvent.hash : 'GENESIS_HASH_00000000000000000000000000000000000000000000000000000000000000';
|
||||
const eventId = `evt-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`;
|
||||
const occurredAt = new Date().toISOString();
|
||||
const correlationId = `corr-${Date.now().toString(36)}`;
|
||||
|
||||
const hashPayload = `${previousHash}|${occurredAt}|${activeOperator.id}|${activeOperator.role}|${action}|${target.type}:${target.id}|${reason}|${correlationId}`;
|
||||
const hash = await computeSha256(hashPayload);
|
||||
|
||||
const event: OperatorAuditEvent = {
|
||||
eventId,
|
||||
occurredAt,
|
||||
operatorId: activeOperator.id,
|
||||
operatorName: activeOperator.name,
|
||||
operatorRole: activeOperator.role,
|
||||
action,
|
||||
target,
|
||||
reason,
|
||||
impactSummary,
|
||||
correlationId,
|
||||
previousHash,
|
||||
hash,
|
||||
};
|
||||
|
||||
await persistItem('audit_events', event);
|
||||
set((state) => ({ auditLog: [event, ...state.auditLog] }));
|
||||
return event;
|
||||
},
|
||||
|
||||
updateOrganizationStatus: async (tenantId, status, reason) => {
|
||||
const { organizations, recordAuditEvent } = get();
|
||||
const org = organizations.find((o) => o.tenantId === tenantId);
|
||||
if (!org) return;
|
||||
|
||||
const oldStatus = org.platformStatus;
|
||||
const updated: OrganizationProjection = {
|
||||
...org,
|
||||
platformStatus: status,
|
||||
projectedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await persistItem('organizations', updated);
|
||||
set((state) => ({
|
||||
organizations: state.organizations.map((o) => (o.tenantId === tenantId ? updated : o)),
|
||||
}));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'organization.status_changed',
|
||||
target: {
|
||||
type: 'organization',
|
||||
id: tenantId,
|
||||
displayName: org.displayName,
|
||||
tenantId,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Статус организации изменен с «${oldStatus}» на «${status}»`,
|
||||
});
|
||||
},
|
||||
|
||||
grantModuleTrial: async (tenantId, moduleId, durationDays, reason) => {
|
||||
const { organizations, modules, recordAuditEvent } = get();
|
||||
const org = organizations.find((o) => o.tenantId === tenantId);
|
||||
const mod = modules.find((m) => m.moduleId === moduleId);
|
||||
if (!org || !mod) return;
|
||||
|
||||
const trialUntil = new Date(Date.now() + durationDays * 24 * 3600 * 1000).toISOString();
|
||||
const updated: OrganizationProjection = {
|
||||
...org,
|
||||
installedModulesCount: org.installedModulesCount + 1,
|
||||
tariffPlan: `${org.tariffPlan} (+Trial ${mod.name})`,
|
||||
projectedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await persistItem('organizations', updated);
|
||||
set((state) => ({
|
||||
organizations: state.organizations.map((o) => (o.tenantId === tenantId ? updated : o)),
|
||||
}));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'entitlement.trial_granted',
|
||||
target: {
|
||||
type: 'organization',
|
||||
id: tenantId,
|
||||
displayName: org.displayName,
|
||||
tenantId,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Выдан пробный доступ к модулю «${mod.name}» на ${durationDays} дней (до ${trialUntil.slice(0, 10)})`,
|
||||
});
|
||||
},
|
||||
|
||||
grantGracePeriod: async (subscriptionId, days, reason) => {
|
||||
const { subscriptions, recordAuditEvent } = get();
|
||||
const sub = subscriptions.find((s) => s.subscriptionId === subscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
const graceUntil = new Date(Date.now() + days * 24 * 3600 * 1000).toISOString();
|
||||
const updated: Subscription = {
|
||||
...sub,
|
||||
status: 'grace',
|
||||
graceUntil,
|
||||
history: [
|
||||
{
|
||||
eventAt: new Date().toISOString(),
|
||||
type: 'grace_granted',
|
||||
description: `Предоставлен льготный период на ${days} дней`,
|
||||
actor: get().activeOperator.name,
|
||||
},
|
||||
...sub.history,
|
||||
],
|
||||
};
|
||||
|
||||
await persistItem('subscriptions', updated);
|
||||
set((state) => ({
|
||||
subscriptions: state.subscriptions.map((s) => (s.subscriptionId === subscriptionId ? updated : s)),
|
||||
}));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'subscription.grace_period_granted',
|
||||
target: {
|
||||
type: 'subscription',
|
||||
id: subscriptionId,
|
||||
displayName: sub.tenantName,
|
||||
tenantId: sub.tenantId,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Установлен Grace Period на ${days} дней (до ${graceUntil.slice(0, 10)})`,
|
||||
});
|
||||
},
|
||||
|
||||
renewSubscription: async (subscriptionId, newPeriod, activeModules, reason) => {
|
||||
const { subscriptions, recordAuditEvent } = get();
|
||||
const sub = subscriptions.find((s) => s.subscriptionId === subscriptionId);
|
||||
if (!sub) return;
|
||||
|
||||
const currentEnd = new Date(sub.validUntil).getTime();
|
||||
const addMs = (newPeriod === 'year' ? 365 : 30) * 24 * 3600 * 1000;
|
||||
const newUntil = new Date(Math.max(Date.now(), currentEnd) + addMs).toISOString();
|
||||
|
||||
const updated: Subscription = {
|
||||
...sub,
|
||||
period: newPeriod,
|
||||
activeModules,
|
||||
status: 'normal',
|
||||
validUntil: newUntil,
|
||||
renewsAt: newUntil,
|
||||
graceUntil: null,
|
||||
history: [
|
||||
{
|
||||
eventAt: new Date().toISOString(),
|
||||
type: 'renewed',
|
||||
description: `Продление тарифа на ${newPeriod === 'year' ? '1 год' : '1 месяц'}`,
|
||||
actor: get().activeOperator.name,
|
||||
},
|
||||
...sub.history,
|
||||
],
|
||||
};
|
||||
|
||||
await persistItem('subscriptions', updated);
|
||||
set((state) => ({
|
||||
subscriptions: state.subscriptions.map((s) => (s.subscriptionId === subscriptionId ? updated : s)),
|
||||
}));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'subscription.renewed',
|
||||
target: {
|
||||
type: 'subscription',
|
||||
id: subscriptionId,
|
||||
displayName: sub.tenantName,
|
||||
tenantId: sub.tenantId,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Продление подписки до ${newUntil.slice(0, 10)}, активных модулей: ${activeModules.length}`,
|
||||
});
|
||||
},
|
||||
|
||||
createIncidentFromAlert: async (serverId, summary, severity, reason) => {
|
||||
const { servers, instances, recordAuditEvent, incidents } = get();
|
||||
const srv = servers.find((s) => s.serverId === serverId);
|
||||
const affectedTenantIds = instances
|
||||
.filter((i) => i.serverId === serverId)
|
||||
.map((i) => i.tenantId);
|
||||
const affectedModuleIds = Array.from(
|
||||
new Set(instances.filter((i) => i.serverId === serverId).map((i) => i.moduleId))
|
||||
);
|
||||
|
||||
const incNumber = `INC-${1050 + incidents.length}`;
|
||||
const incidentId = `inc-${Date.now().toString(36)}`;
|
||||
const openedAt = new Date().toISOString();
|
||||
|
||||
const newIncident: Incident = {
|
||||
incidentId,
|
||||
incidentNumber: incNumber,
|
||||
severity,
|
||||
status: 'open',
|
||||
summary,
|
||||
description: `Инцидент открыт оператором из телеметрического оповещения узла ${srv ? srv.name : serverId}.`,
|
||||
affectedTenantIds,
|
||||
affectedServerIds: [serverId],
|
||||
affectedModuleIds,
|
||||
ownerName: get().activeOperator.name,
|
||||
openedAt,
|
||||
timeline: [
|
||||
{
|
||||
timestamp: openedAt,
|
||||
author: get().activeOperator.name,
|
||||
message: `Инцидент зарегистрирован в системе. Причина: ${reason}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await persistItem('incidents', newIncident);
|
||||
set((state) => ({ incidents: [newIncident, ...state.incidents] }));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'incident.created',
|
||||
target: {
|
||||
type: 'incident',
|
||||
id: incidentId,
|
||||
displayName: `${incNumber} (${summary})`,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Создан инцидент ${incNumber} со степенью критичности ${severity}`,
|
||||
});
|
||||
},
|
||||
|
||||
resolveIncident: async (incidentId, reason) => {
|
||||
const { incidents, recordAuditEvent, activeOperator } = get();
|
||||
const inc = incidents.find((i) => i.incidentId === incidentId);
|
||||
if (!inc) return;
|
||||
|
||||
const resolvedAt = new Date().toISOString();
|
||||
const updated: Incident = {
|
||||
...inc,
|
||||
status: 'resolved',
|
||||
resolvedAt,
|
||||
timeline: [
|
||||
...inc.timeline,
|
||||
{
|
||||
timestamp: resolvedAt,
|
||||
author: activeOperator.name,
|
||||
message: `Инцидент переведен в статус resolved. Решение: ${reason}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await persistItem('incidents', updated);
|
||||
set((state) => ({
|
||||
incidents: state.incidents.map((i) => (i.incidentId === incidentId ? updated : i)),
|
||||
}));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'incident.resolved',
|
||||
target: {
|
||||
type: 'incident',
|
||||
id: incidentId,
|
||||
displayName: inc.incidentNumber,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Инцидент ${inc.incidentNumber} успешно разрешен`,
|
||||
});
|
||||
},
|
||||
|
||||
createAnnouncement: async (announcementData, reason) => {
|
||||
const { recordAuditEvent, activeOperator, organizations } = get();
|
||||
const announcementId = `anc-${Date.now().toString(36)}`;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
let targetCount = organizations.length;
|
||||
if (announcementData.audience.tenantIds && announcementData.audience.tenantIds.length > 0) {
|
||||
targetCount = announcementData.audience.tenantIds.length;
|
||||
}
|
||||
|
||||
const newAnnouncement: Announcement = {
|
||||
...announcementData,
|
||||
announcementId,
|
||||
createdAt,
|
||||
author: activeOperator.name,
|
||||
stats: {
|
||||
totalTargeted: targetCount,
|
||||
queued: targetCount,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await persistItem('announcements', newAnnouncement);
|
||||
set((state) => ({ announcements: [newAnnouncement, ...state.announcements] }));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'announcement.created',
|
||||
target: {
|
||||
type: 'announcement',
|
||||
id: announcementId,
|
||||
displayName: announcementData.title,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Создано оповещение «${announcementData.title}» для ${targetCount} организаций`,
|
||||
});
|
||||
},
|
||||
|
||||
triggerRolloutStage: async (rolloutId, stage, percentage, reason) => {
|
||||
const { rollouts, recordAuditEvent } = get();
|
||||
const rol = rollouts.find((r) => r.rolloutId === rolloutId);
|
||||
if (!rol) return;
|
||||
|
||||
const updated: Rollout = {
|
||||
...rol,
|
||||
stage,
|
||||
percentage,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await persistItem('rollouts', updated);
|
||||
set((state) => ({
|
||||
rollouts: state.rollouts.map((r) => (r.rolloutId === rolloutId ? updated : r)),
|
||||
}));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: `rollout.stage_${stage}`,
|
||||
target: {
|
||||
type: 'rollout',
|
||||
id: rolloutId,
|
||||
displayName: `${rol.moduleName} ${rol.targetVersion}`,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Раскатка переведена в стадию «${stage}» (${percentage}%)`,
|
||||
});
|
||||
},
|
||||
|
||||
createRollout: async (moduleId, version, stage, pilotTenantIds, percentage, notes, reason) => {
|
||||
const { modules, recordAuditEvent } = get();
|
||||
const mod = modules.find((m) => m.moduleId === moduleId);
|
||||
const rolloutId = `rol-${Date.now().toString(36)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newRollout: Rollout = {
|
||||
rolloutId,
|
||||
moduleId,
|
||||
moduleName: mod ? mod.name : moduleId,
|
||||
targetVersion: version,
|
||||
stage,
|
||||
pilotTenantIds,
|
||||
percentage,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
notes,
|
||||
};
|
||||
|
||||
await persistItem('rollouts', newRollout);
|
||||
set((state) => ({ rollouts: [newRollout, ...state.rollouts] }));
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'rollout.created',
|
||||
target: {
|
||||
type: 'rollout',
|
||||
id: rolloutId,
|
||||
displayName: `${newRollout.moduleName} ${version}`,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Инициирован конвейер раскатки ${newRollout.moduleName} ${version} (стадия ${stage})`,
|
||||
});
|
||||
},
|
||||
|
||||
requestServerDiagnostics: async (serverId, reason) => {
|
||||
const { servers, recordAuditEvent } = get();
|
||||
const srv = servers.find((s) => s.serverId === serverId);
|
||||
if (!srv) return;
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'infrastructure.diagnostics_requested',
|
||||
target: {
|
||||
type: 'infrastructure',
|
||||
id: serverId,
|
||||
displayName: srv.name,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Запрошен полный дамп телеметрии и логов с узла ${srv.name}`,
|
||||
});
|
||||
},
|
||||
|
||||
scheduleServerMaintenance: async (serverId, reason) => {
|
||||
const { servers, recordAuditEvent } = get();
|
||||
const srv = servers.find((s) => s.serverId === serverId);
|
||||
if (!srv) return;
|
||||
|
||||
await recordAuditEvent({
|
||||
action: 'infrastructure.maintenance_scheduled',
|
||||
target: {
|
||||
type: 'infrastructure',
|
||||
id: serverId,
|
||||
displayName: srv.name,
|
||||
},
|
||||
reason,
|
||||
impactSummary: `Запланировано регламентное техническое обслуживание для узла ${srv.name}`,
|
||||
});
|
||||
},
|
||||
|
||||
resetToDefaults: async () => {
|
||||
await get().initialize(true);
|
||||
},
|
||||
}));
|
||||
|
||||
export function useOperatorRole() {
|
||||
const activeRole = useKccStore((state) => state.activeRole);
|
||||
const activeOperator = useKccStore((state) => state.activeOperator);
|
||||
const roleDef = OPERATOR_ROLE_DEFINITIONS[activeRole];
|
||||
return {
|
||||
role: activeRole,
|
||||
operator: activeOperator,
|
||||
definition: roleDef,
|
||||
permissions: roleDef.permissions,
|
||||
};
|
||||
}
|
||||
162
src/features/control-center/tests/components.test.tsx
Normal file
162
src/features/control-center/tests/components.test.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
import { ConfirmOperatorActionModal } from '../components/ConfirmOperatorAction';
|
||||
import { OperatorSessionSwitcher } from '../components/OperatorSessionSwitcher';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
|
||||
describe('Control Center UI Components', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store modal state
|
||||
useKccStore.setState({
|
||||
pendingConfirmModal: null,
|
||||
activeOperator: {
|
||||
id: 'usr-owner-01',
|
||||
name: 'Виктор Власов',
|
||||
role: 'platform_owner',
|
||||
email: 'vlasov@krona.platform',
|
||||
title: 'Chief Platform Architect & Owner',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it('renders active status with accessible text and default label', () => {
|
||||
render(<StatusBadge status="active" />);
|
||||
const badge = screen.getByTestId('status-badge');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveAttribute('data-status', 'active');
|
||||
expect(badge).toHaveTextContent('Активна');
|
||||
});
|
||||
|
||||
it('renders degraded status with alert indication', () => {
|
||||
render(<StatusBadge status="degraded" />);
|
||||
const badge = screen.getByTestId('status-badge');
|
||||
expect(badge).toHaveTextContent('Деградация');
|
||||
});
|
||||
|
||||
it('renders custom label when provided', () => {
|
||||
render(<StatusBadge status="critical" customLabel="Критический сбой кластера" />);
|
||||
const badge = screen.getByTestId('status-badge');
|
||||
expect(badge).toHaveTextContent('Критический сбой кластера');
|
||||
});
|
||||
|
||||
it('supports small and medium sizes without breaking markup', () => {
|
||||
const { rerender } = render(<StatusBadge status="healthy" size="sm" />);
|
||||
expect(screen.getByTestId('status-badge')).toHaveClass('text-xs');
|
||||
|
||||
rerender(<StatusBadge status="healthy" size="md" />);
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MoneyDisplay', () => {
|
||||
it('formats minor units to RUB currency string', () => {
|
||||
render(<MoneyDisplay minorUnits={1490000} />);
|
||||
// Ru locale uses non-breaking space
|
||||
expect(screen.getByText(/14[\s\u00A0]?900/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays free label for zero amount when showZeroAsFree is enabled', () => {
|
||||
render(<MoneyDisplay minorUnits={0} showZeroAsFree={true} />);
|
||||
expect(screen.getByText(/0 ₽ \(Бесплатно \/ Trial\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmOperatorActionModal', () => {
|
||||
it('does not render when pendingConfirmModal is null', () => {
|
||||
render(<ConfirmOperatorActionModal />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dialog when pending modal is queued and validates minimum reason length', async () => {
|
||||
const mockConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
useKccStore.setState({
|
||||
pendingConfirmModal: {
|
||||
title: 'Приостановка организации',
|
||||
actionName: 'tenant.suspend',
|
||||
impactSummary: 'Доступ всех пользователей организации будет временно ограничен.',
|
||||
targetType: 'organization',
|
||||
targetId: 'tenant-001',
|
||||
targetName: 'ООО Ритейл Групп',
|
||||
onConfirm: mockConfirm,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ConfirmOperatorActionModal />);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Приостановка организации')).toBeInTheDocument();
|
||||
expect(screen.getByText('ООО Ритейл Групп')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Доступ всех пользователей/)).toBeInTheDocument();
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /Подтвердить операцию/i });
|
||||
const textarea = screen.getByPlaceholderText(/Укажите причину/i);
|
||||
|
||||
// Try submit empty
|
||||
fireEvent.click(submitBtn);
|
||||
expect(await screen.findByText(/Обоснование операции обязательно/i)).toBeInTheDocument();
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// Enter short reason (< 5 chars)
|
||||
fireEvent.change(textarea, { target: { value: 'fix' } });
|
||||
fireEvent.click(submitBtn);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// Enter valid reason (>= 5 chars)
|
||||
fireEvent.change(textarea, { target: { value: 'Плановое отключение по заявке #49021' } });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledWith('Плановое отключение по заявке #49021');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels modal when Cancel button is clicked', () => {
|
||||
useKccStore.setState({
|
||||
pendingConfirmModal: {
|
||||
title: 'Тестовое действие',
|
||||
actionName: 'test.action',
|
||||
impactSummary: 'Влияние',
|
||||
targetType: 'module',
|
||||
targetId: 'm1',
|
||||
targetName: 'Модуль',
|
||||
onConfirm: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
render(<ConfirmOperatorActionModal />);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /Отмена/i });
|
||||
fireEvent.click(cancelBtn);
|
||||
|
||||
expect(useKccStore.getState().pendingConfirmModal).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OperatorSessionSwitcher', () => {
|
||||
it('renders current operator profile and allows role selection', () => {
|
||||
const onRoleChange = vi.fn();
|
||||
render(<OperatorSessionSwitcher onRoleChange={onRoleChange} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Сессия оператора/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.getByText('Виктор Власов')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Владелец платформы/i)).toBeInTheDocument();
|
||||
|
||||
// Open menu
|
||||
fireEvent.click(button);
|
||||
|
||||
// Select 'Оператор поддержки' (support_operator)
|
||||
const supportOption = screen.getByText('Оператор поддержки');
|
||||
fireEvent.click(supportOption);
|
||||
|
||||
expect(onRoleChange).toHaveBeenCalledWith('support_operator');
|
||||
expect(useKccStore.getState().activeOperator.role).toBe('support_operator');
|
||||
});
|
||||
});
|
||||
});
|
||||
161
src/features/control-center/tests/contracts.test.ts
Normal file
161
src/features/control-center/tests/contracts.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { computeSha256 } from '../store/db';
|
||||
import type { OperatorAuditEvent } from '../contracts';
|
||||
|
||||
describe('Control Center Contracts & Math Utilities', () => {
|
||||
describe('Minor Units (Kopecks/Cents) Representation', () => {
|
||||
it('converts minor units to major currency units without floating point loss', () => {
|
||||
const minorAmount = 1490000; // 14,900.00 RUB
|
||||
const majorAmount = minorAmount / 100;
|
||||
expect(majorAmount).toBe(14900);
|
||||
|
||||
const smallAmount = 4999; // 49.99 RUB
|
||||
expect(smallAmount / 100).toBe(49.99);
|
||||
|
||||
const zeroAmount = 0;
|
||||
expect(zeroAmount / 100).toBe(0);
|
||||
});
|
||||
|
||||
it('handles addition of multiple module minor unit prices deterministically', () => {
|
||||
const modulePrices = [1490000, 490000, 790000, 1200000]; // in minor units
|
||||
const totalMinor = modulePrices.reduce((acc, p) => acc + p, 0);
|
||||
expect(totalMinor).toBe(3970000);
|
||||
expect(totalMinor / 100).toBe(39700);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expiry Window Calculations', () => {
|
||||
const calculateSubscriptionWindow = (currentPeriodEnd: string, status: string, now: Date) => {
|
||||
const endDate = new Date(currentPeriodEnd);
|
||||
const diffMs = endDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (status === 'grace') return 'grace';
|
||||
if (status === 'suspended') return 'suspended';
|
||||
if (diffDays < 0) return 'overdue';
|
||||
if (diffDays <= 7) return 'expiring_7d';
|
||||
if (diffDays <= 30) return 'expiring_30d';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
it('classifies subscriptions expiring within 7 days correctly', () => {
|
||||
const now = new Date('2026-09-11T00:00:00Z');
|
||||
const in5Days = '2026-09-16T00:00:00Z';
|
||||
expect(calculateSubscriptionWindow(in5Days, 'active', now)).toBe('expiring_7d');
|
||||
});
|
||||
|
||||
it('classifies subscriptions expiring within 30 days correctly', () => {
|
||||
const now = new Date('2026-09-11T00:00:00Z');
|
||||
const in20Days = '2026-10-01T00:00:00Z';
|
||||
expect(calculateSubscriptionWindow(in20Days, 'active', now)).toBe('expiring_30d');
|
||||
});
|
||||
|
||||
it('classifies subscriptions beyond 30 days as normal', () => {
|
||||
const now = new Date('2026-09-11T00:00:00Z');
|
||||
const in60Days = '2026-11-10T00:00:00Z';
|
||||
expect(calculateSubscriptionWindow(in60Days, 'active', now)).toBe('normal');
|
||||
});
|
||||
|
||||
it('classifies grace and suspended states with higher priority', () => {
|
||||
const now = new Date('2026-09-11T00:00:00Z');
|
||||
const pastDate = '2026-09-01T00:00:00Z';
|
||||
expect(calculateSubscriptionWindow(pastDate, 'grace', now)).toBe('grace');
|
||||
expect(calculateSubscriptionWindow(pastDate, 'suspended', now)).toBe('suspended');
|
||||
expect(calculateSubscriptionWindow(pastDate, 'active', now)).toBe('overdue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Immutable Audit Hash Chaining (SHA-256)', () => {
|
||||
it('generates consistent hashes for identical payloads', async () => {
|
||||
const payload = 'PREV_HASH|2026-09-11T00:00:00Z|usr-01|platform_owner|tenant.suspend|tenant-123|Payment overdue|corr-99';
|
||||
const hash1 = await computeSha256(payload);
|
||||
const hash2 = await computeSha256(payload);
|
||||
|
||||
expect(hash1).toBeDefined();
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1.length).toBeGreaterThan(16);
|
||||
});
|
||||
|
||||
it('detects tampering in audit payload', async () => {
|
||||
const validPayload = 'PREV_HASH|2026-09-11T00:00:00Z|usr-01|platform_owner|tenant.suspend|tenant-123|Payment overdue|corr-99';
|
||||
const tamperedPayload = 'PREV_HASH|2026-09-11T00:00:00Z|usr-01|platform_owner|tenant.suspend|tenant-123|Payment FORGIVEN|corr-99';
|
||||
|
||||
const validHash = await computeSha256(validPayload);
|
||||
const tamperedHash = await computeSha256(tamperedPayload);
|
||||
|
||||
expect(validHash).not.toBe(tamperedHash);
|
||||
});
|
||||
|
||||
it('validates a sequential chain of audit events', async () => {
|
||||
const genesisHash = 'GENESIS_HASH_00000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
// Event 1
|
||||
const payload1 = `${genesisHash}|2026-09-11T01:00:00Z|usr-01|platform_owner|platform.start|organization:sys|Initialization|corr-1`;
|
||||
const hash1 = await computeSha256(payload1);
|
||||
|
||||
// Event 2 links to Event 1
|
||||
const payload2 = `${hash1}|2026-09-11T01:05:00Z|usr-02|billing_manager|subscription.renew|subscription:sub-456|Annual renewal|corr-2`;
|
||||
const hash2 = await computeSha256(payload2);
|
||||
|
||||
// Event 3 links to Event 2
|
||||
const payload3 = `${hash2}|2026-09-11T01:10:00Z|usr-03|support_operator|diagnostics.request|infrastructure:node-01|Latency check|corr-3`;
|
||||
const hash3 = await computeSha256(payload3);
|
||||
|
||||
const chain: OperatorAuditEvent[] = [
|
||||
{
|
||||
eventId: 'evt-1',
|
||||
occurredAt: '2026-09-11T01:00:00Z',
|
||||
operatorId: 'usr-01',
|
||||
operatorName: 'Owner',
|
||||
operatorRole: 'platform_owner',
|
||||
action: 'platform.start',
|
||||
target: { type: 'organization', id: 'sys' },
|
||||
reason: 'Initialization',
|
||||
impactSummary: 'Sys init',
|
||||
correlationId: 'corr-1',
|
||||
previousHash: genesisHash,
|
||||
hash: hash1,
|
||||
},
|
||||
{
|
||||
eventId: 'evt-2',
|
||||
occurredAt: '2026-09-11T01:05:00Z',
|
||||
operatorId: 'usr-02',
|
||||
operatorName: 'Billing',
|
||||
operatorRole: 'billing_manager',
|
||||
action: 'subscription.renew',
|
||||
target: { type: 'subscription', id: 'sub-456' },
|
||||
reason: 'Annual renewal',
|
||||
impactSummary: 'Sub renewed',
|
||||
correlationId: 'corr-2',
|
||||
previousHash: hash1,
|
||||
hash: hash2,
|
||||
},
|
||||
{
|
||||
eventId: 'evt-3',
|
||||
occurredAt: '2026-09-11T01:10:00Z',
|
||||
operatorId: 'usr-03',
|
||||
operatorName: 'Support',
|
||||
operatorRole: 'support_operator',
|
||||
action: 'diagnostics.request',
|
||||
target: { type: 'infrastructure', id: 'node-01' },
|
||||
reason: 'Latency check',
|
||||
impactSummary: 'Diag requested',
|
||||
correlationId: 'corr-3',
|
||||
previousHash: hash2,
|
||||
hash: hash3,
|
||||
},
|
||||
];
|
||||
|
||||
// Verifier function
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const item = chain[i];
|
||||
const prevHash = i === 0 ? genesisHash : chain[i - 1].hash;
|
||||
expect(item.previousHash).toBe(prevHash);
|
||||
|
||||
const expectedPayload = `${item.previousHash}|${item.occurredAt}|${item.operatorId}|${item.operatorRole}|${item.action}|${item.target.type}:${item.target.id}|${item.reason}|${item.correlationId}`;
|
||||
const computed = await computeSha256(expectedPayload);
|
||||
expect(item.hash).toBe(computed);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
91
src/features/control-center/tests/rbac.test.ts
Normal file
91
src/features/control-center/tests/rbac.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { OPERATOR_ROLE_DEFINITIONS, OperatorRole } from '../contracts';
|
||||
|
||||
describe('Control Center RBAC & Operator Role Matrix', () => {
|
||||
const roles: OperatorRole[] = [
|
||||
'platform_owner',
|
||||
'platform_admin',
|
||||
'support_operator',
|
||||
'billing_manager',
|
||||
'security_auditor',
|
||||
'release_manager',
|
||||
];
|
||||
|
||||
it('defines all 6 operator roles in OPERATOR_ROLE_DEFINITIONS', () => {
|
||||
roles.forEach((role) => {
|
||||
expect(OPERATOR_ROLE_DEFINITIONS[role]).toBeDefined();
|
||||
expect(OPERATOR_ROLE_DEFINITIONS[role].label).toBeTruthy();
|
||||
expect(OPERATOR_ROLE_DEFINITIONS[role].description).toBeTruthy();
|
||||
expect(OPERATOR_ROLE_DEFINITIONS[role].permissions).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('grants platform_owner full capabilities across all domains', () => {
|
||||
const p = OPERATOR_ROLE_DEFINITIONS.platform_owner.permissions;
|
||||
expect(p.canManagePlatformStatus).toBe(true);
|
||||
expect(p.canGrantTrialGrace).toBe(true);
|
||||
expect(p.canEditEntitlements).toBe(true);
|
||||
expect(p.canTriggerRollouts).toBe(true);
|
||||
expect(p.canPauseRollbackRollouts).toBe(true);
|
||||
expect(p.canManageIncidents).toBe(true);
|
||||
expect(p.canCreateAnnouncements).toBe(true);
|
||||
expect(p.canScheduleMaintenance).toBe(true);
|
||||
expect(p.canRequestDiagnostics).toBe(true);
|
||||
expect(p.canExportAudit).toBe(true);
|
||||
expect(p.canViewBillingDetails).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces support_operator as diagnostic and incident responder without destructive permissions', () => {
|
||||
const p = OPERATOR_ROLE_DEFINITIONS.support_operator.permissions;
|
||||
expect(p.canRequestDiagnostics).toBe(true);
|
||||
expect(p.canManageIncidents).toBe(true);
|
||||
expect(p.canCreateAnnouncements).toBe(true);
|
||||
// Denied destructive/commercial
|
||||
expect(p.canManagePlatformStatus).toBe(false);
|
||||
expect(p.canGrantTrialGrace).toBe(false);
|
||||
expect(p.canTriggerRollouts).toBe(false);
|
||||
expect(p.canPauseRollbackRollouts).toBe(false);
|
||||
expect(p.canEditEntitlements).toBe(false);
|
||||
});
|
||||
|
||||
it('restricts billing_manager to financial operations and forbids infrastructure/rollouts', () => {
|
||||
const p = OPERATOR_ROLE_DEFINITIONS.billing_manager.permissions;
|
||||
expect(p.canViewBillingDetails).toBe(true);
|
||||
expect(p.canGrantTrialGrace).toBe(true);
|
||||
expect(p.canEditEntitlements).toBe(true);
|
||||
// Denied tech/infra
|
||||
expect(p.canTriggerRollouts).toBe(false);
|
||||
expect(p.canPauseRollbackRollouts).toBe(false);
|
||||
expect(p.canScheduleMaintenance).toBe(false);
|
||||
expect(p.canRequestDiagnostics).toBe(false);
|
||||
});
|
||||
|
||||
it('restricts release_manager to rollout workflows and forbids financial modifications', () => {
|
||||
const p = OPERATOR_ROLE_DEFINITIONS.release_manager.permissions;
|
||||
expect(p.canTriggerRollouts).toBe(true);
|
||||
expect(p.canPauseRollbackRollouts).toBe(true);
|
||||
expect(p.canManageIncidents).toBe(true);
|
||||
expect(p.canCreateAnnouncements).toBe(true);
|
||||
expect(p.canScheduleMaintenance).toBe(false);
|
||||
// Denied billing & tenant status
|
||||
expect(p.canManagePlatformStatus).toBe(false);
|
||||
expect(p.canGrantTrialGrace).toBe(false);
|
||||
expect(p.canEditEntitlements).toBe(false);
|
||||
expect(p.canViewBillingDetails).toBe(false);
|
||||
});
|
||||
|
||||
it('isolates security_auditor to immutable audit inspection without mutation capabilities', () => {
|
||||
const p = OPERATOR_ROLE_DEFINITIONS.security_auditor.permissions;
|
||||
expect(p.canExportAudit).toBe(true);
|
||||
expect(p.canViewBillingDetails).toBe(true);
|
||||
// All mutations denied
|
||||
expect(p.canManagePlatformStatus).toBe(false);
|
||||
expect(p.canGrantTrialGrace).toBe(false);
|
||||
expect(p.canEditEntitlements).toBe(false);
|
||||
expect(p.canTriggerRollouts).toBe(false);
|
||||
expect(p.canPauseRollbackRollouts).toBe(false);
|
||||
expect(p.canManageIncidents).toBe(false);
|
||||
expect(p.canCreateAnnouncements).toBe(false);
|
||||
expect(p.canScheduleMaintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { StatCard } from '../../shared/components/StatCard';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { StatCard, Button, Badge, MaturityBadge } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import {
|
||||
TrendingUp,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import {
|
||||
Clock,
|
||||
Star,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { StatCard } from '../../shared/components/StatCard';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, StatCard, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import {
|
||||
Sparkles,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge, Button } from '../../shared/ui';
|
||||
import { Car, Clock, Plus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useDemoStore, BookingSlot } from '../../mock/store';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge, Button } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import { Clock, Plus } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore, DeliveryItem } from '../../mock/store';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge, Button } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import {
|
||||
Truck,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users, Clock, Utensils, Calendar, Printer } from 'lucide-react';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useDemoStore, KdsTicket } from '../../mock/store';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, Button, MaturityBadge, useToast } from '../../shared/ui';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import { BaysView } from './BaysView';
|
|||
import { InventoryModule } from '../inventory/InventoryModule';
|
||||
import { SalesModule } from '../sales/SalesModule';
|
||||
import { INITIAL_CATALOG } from '../../mock/mockData';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Button, Badge, MaturityBadge } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore, PosCartItem } from '../../mock/store';
|
||||
import { INITIAL_CATALOG } from '../../mock/mockData';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Button, Badge, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import {
|
||||
Search,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { StatCard } from '../../shared/components/StatCard';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { Input } from '../../shared/components/Input';
|
||||
import { Badge, Button, StatCard, Modal, Input } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import {
|
||||
Truck,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import React, { useState } from 'react';
|
||||
import { INITIAL_RECIPES, RecipeItem } from '../../mock/mockData';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Badge, Button, Modal } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import { ChefHat } from 'lucide-react';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
|
||||
export const RecipesView: React.FC = () => {
|
||||
const [recipes] = useState<RecipeItem[]>(INITIAL_RECIPES);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDemoStore, WorkOrder } from '../../mock/store';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { Badge, Button, Modal } from '../../shared/ui';
|
||||
import { formatCurrency } from '../../shared/utils/formatters';
|
||||
import { Car, Plus } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import { useDemoStore } from '../../mock/store';
|
|||
import { CAPABILITIES_LIST } from '../../core/capabilities/types';
|
||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { Badge, MaturityBadge, Button, useToast } from '../../shared/ui';
|
||||
import {
|
||||
exportDatabaseSnapshot,
|
||||
importDatabaseSnapshot,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
||||
import { Badge } from '../../shared/components/Badge';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Button } from '../../shared/components/Button';
|
||||
import { Modal } from '../../shared/components/Modal';
|
||||
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
||||
import {
|
||||
Clock,
|
||||
Plus,
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@
|
|||
--color-surface-hover: var(--surface-hover);
|
||||
}
|
||||
|
||||
body {
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
|
@ -272,6 +272,7 @@ body {
|
|||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Custom scrollbars according to design system */
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { Badge, type BadgeProps } from '../ui/Badge';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { Button, type ButtonProps } from '../ui/Button';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { Drawer, type DrawerProps } from '../ui/Drawer';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { EmptyState, type EmptyStateProps } from '../ui/EmptyState';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { Input, type InputProps } from '../ui/Input';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { MaturityBadge, type MaturityBadgeProps, type MaturityLevel } from '../ui/MaturityBadge';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { Modal, type ModalProps } from '../ui/Modal';
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { StatCard, type StatCardProps } from '../ui/StatCard';
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue