feat: add MVP frontend — Zorro BPMN GUI + mock pipeline
- index.html / styles.css / app.js / mock-data.js — vanilla JS SPA - VPN panel (Checkpoint), Impala table picker, LLM provider selector - Mock Zorro BPMN flow: vpnConnect → dailyAnalytics (impala_query → llm_analyze) - BPMN process log with real endpoint names per ZORRO.md API Reference - Markdown result renderer, 6 mock Impala tables with analysis templates - Updated PRD.md and TRD.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d2ab71165
commit
4e076ffd26
29
PRD.md
29
PRD.md
@ -1,23 +1,24 @@
|
|||||||
# Product Requirements Description
|
# Описание продукта (PRD): Автоматизация аналитики через Impala SQL и LLM
|
||||||
|
|
||||||
## Что я хочу автоматизировать
|
## Что я хочу автоматизировать
|
||||||
<!-- Опиши простыми словами, какую боль ты решаешь -->
|
Автоматизировать ежедневный процесс выгрузки данных из корпоративной базы Impala SQL, их интеллектуального анализа с помощью LLM и получения готового отчета в Telegram.
|
||||||
|
|
||||||
|
|
||||||
## Кто будет пользоваться
|
## Кто будет пользоваться
|
||||||
<!-- Финансист? Руководитель? Бухгалтер? -->
|
- Я (как основной пользователь и создатель процесса).
|
||||||
|
- Заинтересованные коллеги из Казахтелекома, которым нужны эти аналитические сводки.
|
||||||
|
|
||||||
## Что должно произойти на выходе
|
## Что должно произойти на выходе
|
||||||
<!-- Какой результат ты получишь, и кто его получает? -->
|
В Telegram-чат бота приходит готовое сообщение, содержащее аналитический вывод в виде простого текста и агрегированные данные в формате Markdown-таблицы.
|
||||||
|
|
||||||
|
|
||||||
## Шаги взаимодействия
|
|
||||||
<!-- 1. Пользователь делает X
|
|
||||||
2. Система проверяет Y
|
|
||||||
3. Если Z — ... иначе — ... -->
|
|
||||||
|
|
||||||
|
## Главные функции
|
||||||
|
1. **Гибкий запуск (Триггеры):** Автоматическое выполнение пайплайна по расписанию ежедневно в 10:00, а также возможность принудительного ручного запуска по кнопке/команде в Telegram-боте.
|
||||||
|
2. **Умное управление VPN:** Автоматическое подключение к корпоративному VPN перед выгрузкой и отключение после отправки (по сохраненным локальным доступам для вашего ПК). Для коллег этот шаг пропускается, так как предполагается, что они будут использовать бота при уже включенном VPN.
|
||||||
|
3. **Интеграция с Impala SQL:** Выполнение скрипта для извлечения и агрегации свежих данных.
|
||||||
|
4. **Кастомизация промптов для LLM:** Возможность отправить боту свой собственный текстовый запрос (промпт) для анализа данных. Если запрос не передан, система использует аналитический промпт по умолчанию.
|
||||||
|
5. **Доставка в Telegram:** Структурирование ответа от ИИ (выводы + таблица в Markdown) и отправка в личный чат пользователя в Telegram.
|
||||||
|
|
||||||
## Что НЕ входит в задачу
|
## Что НЕ входит в задачу
|
||||||
<!-- Чтобы не разрастаться -->
|
- Разработка отдельного дашборда или веб-интерфейса (всё взаимодействие происходит строго через Telegram).
|
||||||
|
- Формирование и пересылка отчетов в виде скачиваемых файлов (Excel, CSV, PDF).
|
||||||
|
- Подключение и сбор данных из других источников/баз данных (только Impala SQL).
|
||||||
|
- Настройка VPN-доступов на устройствах коллег (это остается в их зоне ответственности).
|
||||||
|
|||||||
291
TRD.md
291
TRD.md
@ -1,15 +1,284 @@
|
|||||||
# Technical Requirements Description
|
# TRD: GUI → Zorro BPMN → Impala → LLM (MVP)
|
||||||
|
|
||||||
<!--
|
> **Источники:** [PRD.md](PRD.md) (что), [ZORRO.md](ZORRO.md) (корпоративный BPM, **обязателен**).
|
||||||
Этот файл создаст Claude.
|
>
|
||||||
|
> Telegram-доставка из v1 TRD вынесена в v2 (см. §10). MVP — локальное desktop-использование через браузерный GUI.
|
||||||
|
|
||||||
Открой Claude (Desktop или web), приложи:
|
---
|
||||||
1. этот файл (или скажи "сделай TRD.md")
|
|
||||||
2. PRD.md (твоё описание идеи)
|
|
||||||
3. ZORRO.md (справочник по Zorro)
|
|
||||||
|
|
||||||
Попроси: "Сделай TRD на основе PRD и ZORRO. Опиши какие BPMN-задачи нужны,
|
## 1. Цель MVP
|
||||||
какие API-эндпоинты Zorro вызывать, какие данные передавать."
|
|
||||||
|
|
||||||
Полученный текст положи сюда (замени этот комментарий).
|
Пользователь открывает в браузере локальный GUI и:
|
||||||
-->
|
1. (Опц.) Вводит креды Checkpoint VPN → жмёт «Connect» → видит статус «VPN: up»
|
||||||
|
2. Выбирает таблицу Impala из выпадающего списка (подгружается через `SHOW TABLES`)
|
||||||
|
3. (Опц.) Пишет кастомный промпт или оставляет дефолтный
|
||||||
|
4. Выбирает LLM-провайдера (Ollama local / Anthropic API)
|
||||||
|
5. Жмёт «Run» → GUI стартует BPMN-инстанс в Zorro → ждёт результата → показывает анализ + Markdown-таблицу
|
||||||
|
|
||||||
|
**Zorro оркестрирует пайплайн** (требование компании). GUI — тонкий клиент к Zorro REST API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────┐
|
||||||
|
│ GUI │──────▶│ Zorro BPM API │──────▶│ PostgreSQL │
|
||||||
|
│ (Streamlit, │ │ /process-instances │ │ (engine) │
|
||||||
|
│ localhost:8501)│◀──────│ /process-instances │ └──────────────┘
|
||||||
|
└──────────────────┘ poll │ ?id=... (статус) │
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│ publishes serviceTask
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌─────────────────────┐
|
||||||
|
│ RabbitMQ │◀─────▶│ Python Worker │
|
||||||
|
│ zorrobpm. │ │ (наш код) │
|
||||||
|
│ jobs.* │ │ ─ VPN │
|
||||||
|
└──────────────┘ │ ─ Impala │
|
||||||
|
│ ─ LLM (Ollama/API) │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему так:**
|
||||||
|
- GUI **не делает** SQL/LLM/VPN сам — он только инициирует процесс и опрашивает статус. Это удовлетворяет требование «Zorro обязателен».
|
||||||
|
- Worker — единственный, кто имеет доступ к Impala/LLM/VPN. Бизнес-логика живёт в нём, оркестрация — в BPMN.
|
||||||
|
- VPN-коннект в MVP — тоже отдельный BPMN-процесс `vpnConnect` (быстрый, ради единообразия и логов в Zorro).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Выбор стека
|
||||||
|
|
||||||
|
| Слой | Технология | Почему |
|
||||||
|
|---|---|---|
|
||||||
|
| GUI | **Streamlit** | Один Python-файл, без HTML/JS, виджеты из коробки (selectbox, text_area, button, spinner). Готов за 15 мин. |
|
||||||
|
| Zorro | **DEMO-окружение** `https://api.m11s.zorrobpm.dev` | Без локального деплоя движка. См. [ZORRO.md §Окружения](ZORRO.md#окружения). |
|
||||||
|
| RabbitMQ | **Локальный контейнер** или demo-брокер `rabbitmq.m11s.zorrobpm.dev` | Зависит от доступности извне. Fallback — поднять локально. См. [ZORRO.md §Docker Compose — фронт + воркеры](ZORRO.md#docker-compose--фронт--воркеры). |
|
||||||
|
| Worker | **Python 3.11 + `pika`** (RabbitMQ) + `requests` (Zorro API) | Минимум зависимостей. |
|
||||||
|
| Impala | `impyla` + `thrift_sasl` | LDAP-auth. |
|
||||||
|
| LLM | Абстракция `LLMClient` с двумя бэкендами: `ollama` (`http://localhost:11434/api/generate`) и `anthropic` (SDK) | Переключается env-переменной `LLM_PROVIDER`. |
|
||||||
|
| VPN | **Checkpoint CLI**: `snx` (Linux/mac legacy) или `TracClient`-CLI / `cpsnx`; на macOS — `Endpoint Security E80.x` имеет `cpopenssl`-CLI. На Win — `TracSrvWrapper`. Обёртка через `subprocess` с stdin-инжектом пароля. | Креды берутся из формы GUI per-session, не из `.env`. |
|
||||||
|
| Конфиг | `.env` (статика) + Streamlit `st.session_state` (динамика: VPN-креды, выбранная таблица) | |
|
||||||
|
|
||||||
|
**Что НЕ берём:**
|
||||||
|
- n8n/Airflow/Dify — Zorro и так оркестратор, второй не нужен.
|
||||||
|
- React/Vue — Streamlit покрывает требования.
|
||||||
|
- Локальный Zorro-движок — DEMO работает, экономим 30 мин.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Сущности данных
|
||||||
|
|
||||||
|
### 4.1 Конфиг (`.env`)
|
||||||
|
|
||||||
|
```env
|
||||||
|
ZORRO_API_URL=https://api.m11s.zorrobpm.dev
|
||||||
|
RABBITMQ_HOST=rabbitmq.m11s.zorrobpm.dev # или localhost
|
||||||
|
RABBITMQ_PORT=5672
|
||||||
|
RABBITMQ_USER=guest
|
||||||
|
RABBITMQ_PASSWORD=guest
|
||||||
|
|
||||||
|
IMPALA_HOST=impala.kt.local
|
||||||
|
IMPALA_PORT=21050
|
||||||
|
IMPALA_USER= # вводится в GUI или берётся отсюда
|
||||||
|
IMPALA_PASSWORD=
|
||||||
|
IMPALA_AUTH=LDAP
|
||||||
|
IMPALA_DATABASE=default
|
||||||
|
|
||||||
|
LLM_PROVIDER=ollama # ollama | anthropic
|
||||||
|
OLLAMA_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=llama3.1:8b
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||||
|
|
||||||
|
VPN_GATEWAY=vpn.telecom.kz # CP gateway, дефолт; пользователь в GUI может изменить
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Runtime-объекты
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class VpnCreds: # живёт только в session_state, не на диск
|
||||||
|
gateway: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalysisJob:
|
||||||
|
job_id: str # = Zorro processInstanceId
|
||||||
|
table: str # выбранная таблица Impala
|
||||||
|
user_prompt: str | None
|
||||||
|
llm_provider: Literal["ollama", "anthropic"]
|
||||||
|
status: Literal["running", "done", "failed"]
|
||||||
|
result_markdown: str | None
|
||||||
|
error: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 BPMN-переменные (передаём в `POST /process-instances`)
|
||||||
|
|
||||||
|
| Имя | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `table` | STRING | имя таблицы Impala (`db.table`) |
|
||||||
|
| `userPrompt` | STRING | кастомный промпт (пусто = дефолт) |
|
||||||
|
| `llmProvider` | STRING | `ollama` или `anthropic` |
|
||||||
|
| `requestedBy` | STRING | username (для логов) |
|
||||||
|
|
||||||
|
На выходе worker кладёт через `POST /service-tasks/{id}/complete`:
|
||||||
|
| Имя | Тип | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `resultMarkdown` | STRING | финальный текст для GUI |
|
||||||
|
| `rowCount` | LONG | сколько строк вернул SQL |
|
||||||
|
| `error` | STRING | пусто если ОК |
|
||||||
|
|
||||||
|
См. [ZORRO.md §Типы переменных](ZORRO.md#типы-переменных).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. BPMN-процессы (обязательны)
|
||||||
|
|
||||||
|
### 5.1 `vpnConnect`
|
||||||
|
```
|
||||||
|
[Start] → [ServiceTask: srv_vpn_connect] → [End]
|
||||||
|
```
|
||||||
|
Variables in: `gateway`, `username`, `password`. Out: `vpnStatus` (`up`/`failed`).
|
||||||
|
Worker слушает `zorrobpm.jobs.srv_vpn_connect`, запускает `subprocess` Checkpoint CLI.
|
||||||
|
|
||||||
|
### 5.2 `dailyAnalytics` (главный)
|
||||||
|
```
|
||||||
|
[Start]
|
||||||
|
→ [ServiceTask: srv_impala_query] job=srv_impala_query
|
||||||
|
→ [ServiceTask: srv_llm_analyze] job=srv_llm_analyze
|
||||||
|
→ [End]
|
||||||
|
```
|
||||||
|
- `srv_impala_query`: in `table` → out `dataMarkdown` (STRING), `rowCount` (LONG)
|
||||||
|
- `srv_llm_analyze`: in `dataMarkdown`, `userPrompt`, `llmProvider` → out `resultMarkdown`
|
||||||
|
|
||||||
|
### 5.3 `vpnDisconnect`
|
||||||
|
Триггерится из GUI отдельной кнопкой. Один serviceTask `srv_vpn_disconnect`.
|
||||||
|
|
||||||
|
**Деплой:** `POST /process-definitions` с BPMN XML, делается единоразово при первом запуске GUI (см. [ZORRO.md §Process Definitions](ZORRO.md#process-definitions)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Используемые эндпоинты Zorro
|
||||||
|
|
||||||
|
Все из [ZORRO.md §API Reference](ZORRO.md#api-reference):
|
||||||
|
|
||||||
|
| Метод | Endpoint | Когда |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/process-definitions` | Старт GUI — задеплоить 3 BPMN, если их нет |
|
||||||
|
| `GET` | `/process-definitions?processDefinitionKey=...&latestVersionOnly=true` | Проверить деплой |
|
||||||
|
| `POST` | `/process-instances` | Кнопки Run / Connect VPN / Disconnect VPN |
|
||||||
|
| `GET` | `/process-instances?id={uuid}` | Polling статуса (каждые 1.5 сек) — `completedAt != null` ⇒ готово |
|
||||||
|
| `GET` | `/service-tasks?processInstanceId={uuid}&completed=true` | Достать `resultMarkdown` из последнего таска |
|
||||||
|
|
||||||
|
Worker:
|
||||||
|
| `GET` / consume | `zorrobpm.jobs.<job_type>` (RabbitMQ) | Получить задачу |
|
||||||
|
| `POST` | `/service-tasks/{id}/complete` | Сдать результат |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Checkpoint VPN — детали
|
||||||
|
|
||||||
|
Checkpoint **не имеет универсального CLI на всех ОС**. Worker детектирует ОС и вызывает один из:
|
||||||
|
|
||||||
|
| ОС | Команда | Примечание |
|
||||||
|
|---|---|---|
|
||||||
|
| macOS (E80+) | `/Applications/Endpoint Security VPN.app/Contents/MacOS/cpopenssl_cli` или AppleScript-обёртка над GUI | Часто требует MFA — в MVP считаем без MFA |
|
||||||
|
| Linux | `echo "$PASSWORD" \| snx -s $GATEWAY -u $USERNAME` | Нужен `snx` (CheckPoint SNX) |
|
||||||
|
| Windows | `TracClient.exe connect -s $GATEWAY -u $USERNAME -p $PASSWORD` | Путь зависит от установки |
|
||||||
|
|
||||||
|
Пароль **не логируется**, передаётся через stdin/env, не через argv. Креды живут только в `st.session_state` Streamlit — закрытие вкладки = очистка.
|
||||||
|
|
||||||
|
**Если CLI Checkpoint недоступен на тачке разработчика** → fallback: GUI показывает чекбокс «VPN уже включён вручную», skip BPMN `vpnConnect`. Это честный MVP-выход.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Структура папок
|
||||||
|
|
||||||
|
```
|
||||||
|
kyrykbaev_i/
|
||||||
|
├── PRD.md
|
||||||
|
├── ZORRO.md
|
||||||
|
├── TRD.md
|
||||||
|
├── README.md
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore # .env, __pycache__, venv/, *.log
|
||||||
|
├── requirements.txt
|
||||||
|
├── docker-compose.yaml # rabbitmq локальный (fallback из ZORRO.md)
|
||||||
|
│
|
||||||
|
├── gui/
|
||||||
|
│ ├── app.py # Streamlit entrypoint: `streamlit run gui/app.py`
|
||||||
|
│ ├── zorro_client.py # обёртка над Zorro REST API (deploy, start, poll)
|
||||||
|
│ ├── state.py # session_state helpers (vpn creds, jobs)
|
||||||
|
│ └── components/
|
||||||
|
│ ├── vpn_panel.py # форма gateway/user/pass + connect/disconnect
|
||||||
|
│ ├── query_panel.py # selectbox таблиц, textarea промпта, selectbox LLM
|
||||||
|
│ └── result_panel.py # рендер resultMarkdown + spinner
|
||||||
|
│
|
||||||
|
├── worker/
|
||||||
|
│ ├── main.py # bootstrap: коннект к RabbitMQ, регистрация хендлеров
|
||||||
|
│ ├── handlers/
|
||||||
|
│ │ ├── vpn_connect.py # job=srv_vpn_connect
|
||||||
|
│ │ ├── vpn_disconnect.py # job=srv_vpn_disconnect
|
||||||
|
│ │ ├── impala_query.py # job=srv_impala_query (SHOW TABLES + SELECT)
|
||||||
|
│ │ └── llm_analyze.py # job=srv_llm_analyze
|
||||||
|
│ ├── clients/
|
||||||
|
│ │ ├── impala.py
|
||||||
|
│ │ ├── llm.py # фабрика: ollama | anthropic
|
||||||
|
│ │ └── checkpoint_vpn.py # subprocess-обёртка, OS detect
|
||||||
|
│ ├── zorro_api.py # POST /service-tasks/{id}/complete
|
||||||
|
│ └── formatter.py # rows → Markdown table (tabulate)
|
||||||
|
│
|
||||||
|
├── bpmn/
|
||||||
|
│ ├── vpnConnect.bpmn
|
||||||
|
│ ├── vpnDisconnect.bpmn
|
||||||
|
│ └── dailyAnalytics.bpmn
|
||||||
|
│
|
||||||
|
├── prompts/
|
||||||
|
│ └── default.md
|
||||||
|
│
|
||||||
|
└── sql/
|
||||||
|
└── list_tables.sql # SHOW TABLES IN ${database}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Поток выполнения «Run» (от клика до результата)
|
||||||
|
|
||||||
|
1. GUI: пользователь выбрал `table=sales.orders`, оставил дефолтный промпт, провайдер `ollama`
|
||||||
|
2. GUI → `POST /process-instances` `{processDefinitionKey: "dailyAnalytics", variables: [table, userPrompt, llmProvider, requestedBy]}` → `{id: <piid>}`
|
||||||
|
3. Zorro engine достигает `srv_impala_query` → публикует в `zorrobpm.jobs.srv_impala_query`
|
||||||
|
4. Worker `handlers/impala_query.py` забирает: запускает SQL `SELECT * FROM sales.orders LIMIT 1000`, рендерит Markdown → `POST /service-tasks/{id}/complete` с `dataMarkdown`, `rowCount`
|
||||||
|
5. Zorro идёт дальше → `srv_llm_analyze` публикуется
|
||||||
|
6. Worker `handlers/llm_analyze.py` забирает: дёргает Ollama/Anthropic → `POST /service-tasks/{id}/complete` с `resultMarkdown`
|
||||||
|
7. Процесс завершается, `completedAt` заполняется
|
||||||
|
8. GUI поллит `GET /process-instances?id=<piid>` → видит `completedAt` → `GET /service-tasks?processInstanceId=<piid>` → достаёт `resultMarkdown` → рендерит через `st.markdown(...)`
|
||||||
|
|
||||||
|
Тайминг для GUI: spinner с текстом текущего шага (определяется по последнему completed serviceTask).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Roadmap после MVP
|
||||||
|
|
||||||
|
1. Telegram-канал доставки (вернуть из v1 TRD) как параллельный output процесса `dailyAnalytics` — добавить `srv_telegram_send` после `srv_llm_analyze`.
|
||||||
|
2. Расписание 10:00 — отдельный cron-таймер в BPMN (Zorro `timerStartEvent`) вместо APScheduler.
|
||||||
|
3. Кэш `dataMarkdown` per (table, date) в SQLite на стороне worker — не пере-запрашивать Impala.
|
||||||
|
4. MFA для Checkpoint (push-апрув / TOTP) — отдельный `userTask` в BPMN `vpnConnect`.
|
||||||
|
5. Multi-user GUI через nginx + login (сейчас Streamlit запускается локально каждым юзером).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Чек-лист готовности MVP
|
||||||
|
|
||||||
|
- [ ] `docker compose up -d` поднимает локальный RabbitMQ (если demo-брокер недоступен)
|
||||||
|
- [ ] `python worker/main.py` коннектится к RabbitMQ и слушает 4 очереди
|
||||||
|
- [ ] `streamlit run gui/app.py` открывается на `localhost:8501`
|
||||||
|
- [ ] При первом старте GUI деплоит 3 BPMN в Zorro (или подтверждает, что они уже есть)
|
||||||
|
- [ ] Кнопка «Connect VPN» с заполненными кредами → `vpnStatus=up` в Zorro инстансе
|
||||||
|
- [ ] Selectbox таблиц подгружается (через временный SHOW TABLES — можно прямо из GUI, минуя BPMN, для UX)
|
||||||
|
- [ ] Кнопка «Run» с дефолтным промптом и Ollama → результат в GUI ≤ 90 сек
|
||||||
|
- [ ] Кастомный промпт меняет вывод
|
||||||
|
- [ ] Переключение на Anthropic работает
|
||||||
|
- [ ] Ошибка Impala/LLM → Incident в Zorro (см. [ZORRO.md §Incidents](ZORRO.md#incidents)), GUI показывает «❌ + сообщение»
|
||||||
|
- [ ] Кнопка «Disconnect VPN» отрабатывает
|
||||||
|
- [ ] Пароли VPN/Impala не светятся в логах worker'а
|
||||||
|
|||||||
337
app.js
Normal file
337
app.js
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
// MVP логика: эмулирует поток GUI → Zorro BPMN → Worker → Result.
|
||||||
|
// Все сетевые вызовы заменены на mockApi.* с реалистичными задержками.
|
||||||
|
// Структура и имена эндпоинтов соответствуют TRD.md §6 и ZORRO.md §API Reference.
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
vpnUp: false,
|
||||||
|
running: false,
|
||||||
|
vpnPiId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Mock Zorro API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockApi = {
|
||||||
|
// POST /process-instances → {id}
|
||||||
|
startProcess(processDefinitionKey, variables) {
|
||||||
|
const piId = 'pi-' + crypto.randomUUID().slice(0, 8);
|
||||||
|
logReq(`POST /process-instances`, { processDefinitionKey, variables });
|
||||||
|
return delay(180).then(() => {
|
||||||
|
logRes(`200 OK { id: "${piId}" }`);
|
||||||
|
return { id: piId };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Эмуляция шагов serviceTask: задержка → log "complete"
|
||||||
|
serviceTaskStep(piId, code, durationMs, outputs = {}) {
|
||||||
|
logEvt(`⏵ ServiceTask started: ${code} (job=${code})`);
|
||||||
|
return delay(durationMs).then(() => {
|
||||||
|
const taskId = 'st-' + crypto.randomUUID().slice(0, 6);
|
||||||
|
logReq(`POST /service-tasks/${taskId}/complete`, { variables: outputs });
|
||||||
|
return delay(80).then(() => {
|
||||||
|
logRes(`200 OK`);
|
||||||
|
logOk(`✓ ServiceTask completed: ${code}`);
|
||||||
|
return outputs;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Финал: процесс завершён
|
||||||
|
finishProcess(piId) {
|
||||||
|
logInfo(`processInstance ${piId} → completedAt set`);
|
||||||
|
logOk(`✓ Process completed`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Mock VPN (через BPMN vpnConnect) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function connectVpn() {
|
||||||
|
const gateway = $('#vpn-gateway').value.trim();
|
||||||
|
const user = $('#vpn-user').value.trim();
|
||||||
|
const pass = $('#vpn-pass').value;
|
||||||
|
|
||||||
|
if (!user || !pass) {
|
||||||
|
alert('Введите username и password Checkpoint VPN');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVpnStatus('connecting', `Connecting to ${gateway}...`);
|
||||||
|
disableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass', 'vpn-skip']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pi = await mockApi.startProcess('vpnConnect', [
|
||||||
|
{ name: 'gateway', value: gateway, type: 'STRING' },
|
||||||
|
{ name: 'username', value: user, type: 'STRING' },
|
||||||
|
{ name: 'password', value: '***', type: 'STRING' }, // в реале — тоже в STRING, но не логируем
|
||||||
|
]);
|
||||||
|
state.vpnPiId = pi.id;
|
||||||
|
setPiId(pi.id);
|
||||||
|
|
||||||
|
await mockApi.serviceTaskStep(pi.id, 'srv_vpn_connect', 1400, {
|
||||||
|
vpnStatus: 'up',
|
||||||
|
});
|
||||||
|
mockApi.finishProcess(pi.id);
|
||||||
|
|
||||||
|
state.vpnUp = true;
|
||||||
|
setVpnStatus('up', `Connected · ${user}@${gateway}`);
|
||||||
|
$('#vpn-disconnect').disabled = false;
|
||||||
|
enableQueryPanel();
|
||||||
|
populateTables();
|
||||||
|
} catch (e) {
|
||||||
|
setVpnStatus('failed', `Failed: ${e.message}`);
|
||||||
|
enableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass', 'vpn-skip']);
|
||||||
|
logErr(`✗ ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectVpn() {
|
||||||
|
$('#vpn-disconnect').disabled = true;
|
||||||
|
setVpnStatus('connecting', 'Disconnecting...');
|
||||||
|
|
||||||
|
const pi = await mockApi.startProcess('vpnDisconnect', []);
|
||||||
|
setPiId(pi.id);
|
||||||
|
await mockApi.serviceTaskStep(pi.id, 'srv_vpn_disconnect', 500, { vpnStatus: 'down' });
|
||||||
|
mockApi.finishProcess(pi.id);
|
||||||
|
|
||||||
|
state.vpnUp = false;
|
||||||
|
setVpnStatus('idle', 'Disconnected');
|
||||||
|
enableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass', 'vpn-skip']);
|
||||||
|
disableQueryPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVpnSkipChange() {
|
||||||
|
const skip = $('#vpn-skip').checked;
|
||||||
|
if (skip) {
|
||||||
|
state.vpnUp = true;
|
||||||
|
setVpnStatus('up', 'Skipped (manual VPN)');
|
||||||
|
disableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass']);
|
||||||
|
$('#vpn-disconnect').disabled = true;
|
||||||
|
enableQueryPanel();
|
||||||
|
populateTables();
|
||||||
|
logInfo('VPN skip = true — пропускаем BPMN vpnConnect (по TRD §7 fallback)');
|
||||||
|
} else {
|
||||||
|
state.vpnUp = false;
|
||||||
|
setVpnStatus('idle', 'Disconnected');
|
||||||
|
enableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass']);
|
||||||
|
disableQueryPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tables list (SHOW TABLES) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function populateTables() {
|
||||||
|
const sel = $('#table-select');
|
||||||
|
sel.innerHTML = '<option value="">— выберите таблицу —</option>';
|
||||||
|
MOCK_TABLES.forEach(t => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.name;
|
||||||
|
opt.textContent = `${t.name} (~${t.rows.toLocaleString()} строк · ${t.description})`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
$('#table-hint').innerHTML = `Загружено ${MOCK_TABLES.length} таблиц через <code>SHOW TABLES</code>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Run analysis (главный BPMN dailyAnalytics) ────────────────────────────────
|
||||||
|
|
||||||
|
async function runAnalysis() {
|
||||||
|
const table = $('#table-select').value;
|
||||||
|
const userPrompt = $('#prompt-input').value.trim();
|
||||||
|
const provider = document.querySelector('input[name=llm]:checked').value;
|
||||||
|
|
||||||
|
if (!table) { alert('Выберите таблицу'); return; }
|
||||||
|
|
||||||
|
state.running = true;
|
||||||
|
$('#run-btn').disabled = true;
|
||||||
|
$('#run-btn').textContent = '⏳ Запущено...';
|
||||||
|
$('#result').classList.remove('placeholder');
|
||||||
|
$('#result').textContent = 'Выполняется BPMN-процесс dailyAnalytics...';
|
||||||
|
$('#query-panel').classList.add('running');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pi = await mockApi.startProcess('dailyAnalytics', [
|
||||||
|
{ name: 'table', value: table, type: 'STRING' },
|
||||||
|
{ name: 'userPrompt', value: userPrompt || '', type: 'STRING' },
|
||||||
|
{ name: 'llmProvider', value: provider, type: 'STRING' },
|
||||||
|
{ name: 'requestedBy', value: $('#vpn-user').value || 'anonymous', type: 'STRING' },
|
||||||
|
]);
|
||||||
|
setPiId(pi.id);
|
||||||
|
|
||||||
|
// Step 1: srv_impala_query
|
||||||
|
const queryData = MOCK_QUERY_RESULTS[table];
|
||||||
|
const rowCount = queryData ? queryData.rows.length : 0;
|
||||||
|
logInfo(`Worker (handlers/impala_query.py): SELECT * FROM ${table} LIMIT 1000`);
|
||||||
|
await mockApi.serviceTaskStep(pi.id, 'srv_impala_query', 1200, {
|
||||||
|
dataMarkdown: queryData ? rowsToMarkdown(queryData) : '',
|
||||||
|
rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: srv_llm_analyze
|
||||||
|
const llmDelay = provider === 'ollama' ? 2400 : 1600;
|
||||||
|
logInfo(`Worker (handlers/llm_analyze.py): calling ${provider === 'ollama' ? 'Ollama (llama3.1:8b)' : 'Anthropic (claude-sonnet-4-6)'}...`);
|
||||||
|
const resultMarkdown = buildAnalysis(table, provider, userPrompt);
|
||||||
|
await mockApi.serviceTaskStep(pi.id, 'srv_llm_analyze', llmDelay, {
|
||||||
|
resultMarkdown: '<...>',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockApi.finishProcess(pi.id);
|
||||||
|
|
||||||
|
// Render result
|
||||||
|
$('#result').innerHTML = renderMarkdown(resultMarkdown);
|
||||||
|
} catch (e) {
|
||||||
|
$('#result').innerHTML = `<p style="color: var(--error);">❌ ${e.message}</p>`;
|
||||||
|
logErr(`✗ ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
state.running = false;
|
||||||
|
$('#run-btn').disabled = false;
|
||||||
|
$('#run-btn').textContent = '▶ Run analysis';
|
||||||
|
$('#query-panel').classList.remove('running');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function setVpnStatus(stateName, text) {
|
||||||
|
const dot = $('#vpn-dot');
|
||||||
|
dot.className = 'status-dot ' + (stateName === 'idle' ? '' : stateName);
|
||||||
|
$('#vpn-status-text').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableQueryPanel() {
|
||||||
|
$('#table-select').disabled = false;
|
||||||
|
$('#run-btn').disabled = false;
|
||||||
|
}
|
||||||
|
function disableQueryPanel() {
|
||||||
|
$('#table-select').disabled = true;
|
||||||
|
$('#run-btn').disabled = true;
|
||||||
|
$('#table-select').innerHTML = '<option value="">— подключитесь к VPN —</option>';
|
||||||
|
$('#table-hint').innerHTML = 'Список загружается через <code>SHOW TABLES</code> после VPN';
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableForm(ids) { ids.forEach(id => $('#' + id).disabled = true); }
|
||||||
|
function enableForm(ids) { ids.forEach(id => $('#' + id).disabled = false); }
|
||||||
|
|
||||||
|
function setPiId(id) {
|
||||||
|
$('#pi-id').textContent = `processInstance: ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Logging ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ts() {
|
||||||
|
const d = new Date();
|
||||||
|
return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0');
|
||||||
|
}
|
||||||
|
function appendLog(cls, text, payload) {
|
||||||
|
const log = $('#log');
|
||||||
|
const line = document.createElement('span');
|
||||||
|
line.className = 'log-line ' + cls;
|
||||||
|
let body = text;
|
||||||
|
if (payload !== undefined) body += ' ' + JSON.stringify(payload);
|
||||||
|
line.innerHTML = `<span class="ts">${ts()}</span> ${escapeHtml(body)}`;
|
||||||
|
log.appendChild(line);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
const logReq = (...a) => appendLog('req', '→ ' + a[0], a[1]);
|
||||||
|
const logRes = (...a) => appendLog('res', '← ' + a[0], a[1]);
|
||||||
|
const logEvt = (...a) => appendLog('evt', a[0]);
|
||||||
|
const logOk = (...a) => appendLog('ok', a[0]);
|
||||||
|
const logErr = (...a) => appendLog('err', a[0]);
|
||||||
|
const logInfo = (...a) => appendLog('info', ' ' + a[0]);
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
$('#log').innerHTML = '';
|
||||||
|
setPiId('—');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Markdown renderer (minimal: headers, lists, bold, code, tables, blockquote) ──
|
||||||
|
|
||||||
|
function renderMarkdown(md) {
|
||||||
|
const lines = md.split('\n');
|
||||||
|
let html = '';
|
||||||
|
let inTable = false, tableRows = [];
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
const flushTable = () => {
|
||||||
|
if (tableRows.length < 2) { tableRows = []; inTable = false; return; }
|
||||||
|
const header = tableRows[0];
|
||||||
|
const body = tableRows.slice(2); // skip |---|
|
||||||
|
html += '<table><thead><tr>';
|
||||||
|
header.forEach(c => html += `<th>${inline(c)}</th>`);
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
body.forEach(row => {
|
||||||
|
html += '<tr>';
|
||||||
|
row.forEach(c => html += `<td>${inline(c)}</td>`);
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
tableRows = [];
|
||||||
|
inTable = false;
|
||||||
|
};
|
||||||
|
const flushList = () => { if (inList) { html += '</ul>'; inList = false; } };
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw;
|
||||||
|
if (line.trimStart().startsWith('|') && line.trimEnd().endsWith('|')) {
|
||||||
|
flushList();
|
||||||
|
inTable = true;
|
||||||
|
const cells = line.trim().split('|').slice(1, -1).map(s => s.trim());
|
||||||
|
tableRows.push(cells);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTable) flushTable();
|
||||||
|
|
||||||
|
if (line.startsWith('### ')) { flushList(); html += `<h3>${inline(line.slice(4))}</h3>`; }
|
||||||
|
else if (line.startsWith('## ')) { flushList(); html += `<h2>${inline(line.slice(3))}</h2>`; }
|
||||||
|
else if (line.startsWith('# ')) { flushList(); html += `<h1>${inline(line.slice(2))}</h1>`; }
|
||||||
|
else if (line.startsWith('> ')) { flushList(); html += `<blockquote style="border-left:3px solid var(--accent); padding-left:12px; margin:8px 0; color:var(--text-dim);">${inline(line.slice(2))}</blockquote>`; }
|
||||||
|
else if (line.startsWith('- ')) {
|
||||||
|
if (!inList) { html += '<ul>'; inList = true; }
|
||||||
|
html += `<li>${inline(line.slice(2))}</li>`;
|
||||||
|
} else if (line.trim() === '') {
|
||||||
|
flushList();
|
||||||
|
} else {
|
||||||
|
flushList();
|
||||||
|
html += `<p>${inline(line)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inTable) flushTable();
|
||||||
|
flushList();
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inline(s) {
|
||||||
|
return escapeHtml(s)
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsToMarkdown(qr) {
|
||||||
|
const head = '| ' + qr.columns.join(' | ') + ' |';
|
||||||
|
const sep = '| ' + qr.columns.map(() => '---').join(' | ') + ' |';
|
||||||
|
const body = qr.rows.map(r => '| ' + r.join(' | ') + ' |').join('\n');
|
||||||
|
return [head, sep, body].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
$('#prompt-input').placeholder = DEFAULT_PROMPT;
|
||||||
|
|
||||||
|
$('#vpn-connect').addEventListener('click', connectVpn);
|
||||||
|
$('#vpn-disconnect').addEventListener('click', disconnectVpn);
|
||||||
|
$('#vpn-skip').addEventListener('change', onVpnSkipChange);
|
||||||
|
$('#run-btn').addEventListener('click', runAnalysis);
|
||||||
|
$('#clear-log').addEventListener('click', clearLog);
|
||||||
|
|
||||||
|
logInfo('GUI loaded. Mock Zorro API ready. См. TRD.md §9 для потока выполнения.');
|
||||||
|
});
|
||||||
117
index.html
Normal file
117
index.html
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Impala Analytics — Zorro BPMN MVP</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="header-inner">
|
||||||
|
<div>
|
||||||
|
<h1>Impala Analytics → Zorro BPMN → LLM</h1>
|
||||||
|
<p class="subtitle">MVP · все внешние вызовы мокированы · реализация по TRD.md</p>
|
||||||
|
</div>
|
||||||
|
<div class="env-badge">
|
||||||
|
<span class="env-label">ZORRO_API_URL</span>
|
||||||
|
<span class="env-value">https://api.m11s.zorrobpm.dev</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col">
|
||||||
|
<!-- VPN Panel -->
|
||||||
|
<section class="panel" id="vpn-panel">
|
||||||
|
<h2><span class="num">1</span> Checkpoint VPN</h2>
|
||||||
|
|
||||||
|
<div class="vpn-status" id="vpn-status-box">
|
||||||
|
<span class="status-dot" id="vpn-dot"></span>
|
||||||
|
<span id="vpn-status-text">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Gateway</label>
|
||||||
|
<input type="text" id="vpn-gateway" value="vpn.telecom.kz">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="vpn-user" placeholder="i.kyrykbaev" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="vpn-pass" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="vpn-connect" class="primary">Connect</button>
|
||||||
|
<button id="vpn-disconnect" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" id="vpn-skip">
|
||||||
|
VPN уже включён вручную (пропустить BPMN vpnConnect)
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Query Panel -->
|
||||||
|
<section class="panel" id="query-panel">
|
||||||
|
<h2><span class="num">2</span> Запрос</h2>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Таблица Impala</label>
|
||||||
|
<select id="table-select" disabled>
|
||||||
|
<option value="">— подключитесь к VPN —</option>
|
||||||
|
</select>
|
||||||
|
<small class="hint" id="table-hint">Список загружается через <code>SHOW TABLES</code> после VPN</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Промпт для LLM (необязательно)</label>
|
||||||
|
<textarea id="prompt-input" rows="5" placeholder="Оставьте пустым — будет использован дефолтный аналитический промпт"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>LLM провайдер</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label class="radio"><input type="radio" name="llm" value="ollama" checked> <span>Ollama (local)</span> <small>llama3.1:8b</small></label>
|
||||||
|
<label class="radio"><input type="radio" name="llm" value="anthropic"> <span>Anthropic API</span> <small>claude-sonnet-4-6</small></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="run-btn" class="primary big" disabled>▶ Run analysis</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<!-- Process Log -->
|
||||||
|
<section class="panel" id="log-panel">
|
||||||
|
<h2><span class="num">3</span> BPMN Process Log</h2>
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<span class="pi-id" id="pi-id">processInstance: —</span>
|
||||||
|
<button id="clear-log" class="link">очистить</button>
|
||||||
|
</div>
|
||||||
|
<div id="log" class="log"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<section class="panel" id="result-panel">
|
||||||
|
<h2><span class="num">4</span> Результат</h2>
|
||||||
|
<div id="result" class="result placeholder">
|
||||||
|
Запустите анализ, чтобы увидеть результат от LLM.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<small>kyrykbaev_i · MVP для хакатона · GUI = HTML/CSS/JS · Zorro/Impala/LLM/VPN мокированы для демо</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="mock-data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
215
mock-data.js
Normal file
215
mock-data.js
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
// Mock-данные, имитирующие то, что вернул бы реальный воркер:
|
||||||
|
// - список таблиц из `SHOW TABLES`
|
||||||
|
// - результат SELECT (rows + columns)
|
||||||
|
// - аналитический ответ от LLM (Markdown)
|
||||||
|
//
|
||||||
|
// В реальной системе всё это приходит через Zorro serviceTask → POST /service-tasks/{id}/complete.
|
||||||
|
|
||||||
|
const MOCK_TABLES = [
|
||||||
|
{ name: 'sales.orders', rows: 1247, description: 'Заказы по регионам' },
|
||||||
|
{ name: 'sales.customers', rows: 8932, description: 'Клиентская база' },
|
||||||
|
{ name: 'marketing.campaigns', rows: 156, description: 'Маркетинговые кампании' },
|
||||||
|
{ name: 'support.tickets', rows: 4521, description: 'Обращения в техподдержку' },
|
||||||
|
{ name: 'billing.invoices', rows: 12044, description: 'Выставленные счета' },
|
||||||
|
{ name: 'network.incidents', rows: 287, description: 'Сетевые инциденты' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_QUERY_RESULTS = {
|
||||||
|
'sales.orders': {
|
||||||
|
columns: ['region', 'orders', 'revenue_kzt', 'avg_check_kzt'],
|
||||||
|
rows: [
|
||||||
|
['Almaty', 4521, 187_300_000, 41_430],
|
||||||
|
['Astana', 3210, 142_500_000, 44_392],
|
||||||
|
['Shymkent', 1980, 67_400_000, 34_040],
|
||||||
|
['Karaganda', 1102, 38_900_000, 35_300],
|
||||||
|
['Aktobe', 856, 24_100_000, 28_155],
|
||||||
|
['Pavlodar', 643, 19_800_000, 30_793],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'sales.customers': {
|
||||||
|
columns: ['segment', 'customers', 'churn_rate_pct', 'ltv_kzt'],
|
||||||
|
rows: [
|
||||||
|
['B2B Enterprise', 124, 2.1, 3_450_000],
|
||||||
|
['B2B SMB', 1843, 8.7, 420_000],
|
||||||
|
['B2C Premium', 2104, 4.3, 180_000],
|
||||||
|
['B2C Standard', 4861, 12.4, 65_000],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'marketing.campaigns': {
|
||||||
|
columns: ['campaign', 'impressions', 'ctr_pct', 'conversions', 'cpa_kzt'],
|
||||||
|
rows: [
|
||||||
|
['New Year Promo', 1_240_000, 3.2, 892, 1_240],
|
||||||
|
['Summer Internet', 820_000, 2.7, 421, 1_980],
|
||||||
|
['Family Plan', 540_000, 4.1, 612, 880],
|
||||||
|
['IPTV Bundle', 310_000, 1.9, 118, 2_630],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'support.tickets': {
|
||||||
|
columns: ['category', 'opened', 'resolved', 'avg_resolution_h'],
|
||||||
|
rows: [
|
||||||
|
['Internet', 2104, 1987, 4.2],
|
||||||
|
['IPTV', 842, 801, 6.8],
|
||||||
|
['Billing', 721, 712, 2.1],
|
||||||
|
['Mobile', 512, 490, 3.5],
|
||||||
|
['Hardware', 342, 301, 18.4],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'billing.invoices': {
|
||||||
|
columns: ['month', 'issued', 'paid', 'overdue', 'total_kzt'],
|
||||||
|
rows: [
|
||||||
|
['2026-01', 4012, 3812, 200, 480_200_000],
|
||||||
|
['2026-02', 4087, 3901, 186, 491_400_000],
|
||||||
|
['2026-03', 4133, 3877, 256, 502_100_000],
|
||||||
|
['2026-04', 4201, 4044, 157, 514_800_000],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'network.incidents': {
|
||||||
|
columns: ['severity', 'count', 'mttr_minutes', 'affected_users'],
|
||||||
|
rows: [
|
||||||
|
['P1 Critical', 8, 42, 12_400],
|
||||||
|
['P2 High', 34, 87, 3_210],
|
||||||
|
['P3 Medium', 102, 184, 640],
|
||||||
|
['P4 Low', 143, 320, 54],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT = `Ты — старший аналитик данных в Казахтелекоме.
|
||||||
|
Проанализируй приложенную таблицу и:
|
||||||
|
1. Дай 3-5 ключевых выводов в виде маркированного списка.
|
||||||
|
2. Подсвети аномалии и точки роста.
|
||||||
|
3. Сформулируй 1-2 рекомендации для бизнеса.
|
||||||
|
4. В конце выведи исходную таблицу в Markdown.`;
|
||||||
|
|
||||||
|
// Шаблоны "анализа" — то, что вернул бы реальный LLM.
|
||||||
|
// Зависят от выбранной таблицы и провайдера (для разнообразия).
|
||||||
|
const MOCK_ANALYSES = {
|
||||||
|
'sales.orders': (provider) => `## Аналитика по таблице \`sales.orders\`
|
||||||
|
|
||||||
|
**Ключевые наблюдения:**
|
||||||
|
- **Almaty** генерирует ~38% общей выручки (187.3 млн ₸ из ~480 млн ₸) — главный драйвер.
|
||||||
|
- **Astana** второй по объёму, но **средний чек выше** (44 392 ₸ против 41 430 ₸ в Алматы) — премиум-сегмент сильнее.
|
||||||
|
- **Shymkent**: при 3-м месте по числу заказов средний чек **на 18% ниже** среднего — низкомаржинальный регион.
|
||||||
|
- Регионы **Aktobe** и **Pavlodar** показывают наименьший средний чек (28-31 тыс. ₸), что коррелирует с региональной покупательной способностью.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
- Запустить пилот upsell-программы в Шымкенте для подтягивания среднего чека.
|
||||||
|
- В Астане усилить B2B-предложения — премиум-аудитория готова платить больше.
|
||||||
|
|
||||||
|
**Исходные данные** (источник: \`sales.orders\`, ${provider === 'ollama' ? 'llama3.1:8b' : 'claude-sonnet-4-6'}):
|
||||||
|
|
||||||
|
| region | orders | revenue_kzt | avg_check_kzt |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Almaty | 4521 | 187 300 000 | 41 430 |
|
||||||
|
| Astana | 3210 | 142 500 000 | 44 392 |
|
||||||
|
| Shymkent | 1980 | 67 400 000 | 34 040 |
|
||||||
|
| Karaganda | 1102 | 38 900 000 | 35 300 |
|
||||||
|
| Aktobe | 856 | 24 100 000 | 28 155 |
|
||||||
|
| Pavlodar | 643 | 19 800 000 | 30 793 |`,
|
||||||
|
|
||||||
|
'sales.customers': (provider) => `## Аналитика по таблице \`sales.customers\`
|
||||||
|
|
||||||
|
**Выводы:**
|
||||||
|
- Сегмент **B2B Enterprise** даёт самый высокий LTV (3.45 млн ₸) при минимальном churn (2.1%) — стратегически важный сегмент.
|
||||||
|
- **B2C Standard** — крупнейший по числу клиентов (4 861), но **churn 12.4%** — критическая зона.
|
||||||
|
- LTV B2C Standard в **53 раза ниже**, чем B2B Enterprise — оправдывает фокус сейлзов на крупных контрактах.
|
||||||
|
|
||||||
|
**Аномалии:**
|
||||||
|
- B2B SMB с churn 8.7% выпадает из ожидаемой картины «B2B = лояльность» — стоит расследовать причины оттока.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
- Retention-кампания для B2C Standard в первый месяц жизни клиента.
|
||||||
|
- Account-management программа для B2B SMB.
|
||||||
|
|
||||||
|
| segment | customers | churn_rate_pct | ltv_kzt |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B2B Enterprise | 124 | 2.1 | 3 450 000 |
|
||||||
|
| B2B SMB | 1843 | 8.7 | 420 000 |
|
||||||
|
| B2C Premium | 2104 | 4.3 | 180 000 |
|
||||||
|
| B2C Standard | 4861 | 12.4 | 65 000 |`,
|
||||||
|
|
||||||
|
'marketing.campaigns': (provider) => `## Аналитика по таблице \`marketing.campaigns\`
|
||||||
|
|
||||||
|
**Выводы:**
|
||||||
|
- **Family Plan** — лидер по эффективности: CTR 4.1% и самая низкая CPA (880 ₸).
|
||||||
|
- **IPTV Bundle** — наихудший ROAS: CPA 2 630 ₸ при низком CTR 1.9%. Кандидат на остановку или пересмотр креативов.
|
||||||
|
- **New Year Promo** дал максимум конверсий (892) благодаря объёму показов, но эффективность средняя.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
- Перераспределить бюджет с IPTV Bundle на Family Plan.
|
||||||
|
- A/B-тест креативов IPTV Bundle перед следующим запуском.
|
||||||
|
|
||||||
|
| campaign | impressions | ctr_pct | conversions | cpa_kzt |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| New Year Promo | 1 240 000 | 3.2 | 892 | 1 240 |
|
||||||
|
| Summer Internet | 820 000 | 2.7 | 421 | 1 980 |
|
||||||
|
| Family Plan | 540 000 | 4.1 | 612 | 880 |
|
||||||
|
| IPTV Bundle | 310 000 | 1.9 | 118 | 2 630 |`,
|
||||||
|
|
||||||
|
'support.tickets': (provider) => `## Аналитика по таблице \`support.tickets\`
|
||||||
|
|
||||||
|
**Выводы:**
|
||||||
|
- Категория **Hardware** имеет аномально высокое время решения (18.4 ч против 4.2 ч в Internet) — узкое горлышко.
|
||||||
|
- **Billing** — самая эффективная категория: 99% resolution rate, 2.1 ч на тикет.
|
||||||
|
- **Internet** генерирует ~46% всех тикетов — стоит инвестировать в self-service / FAQ.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
- Аудит SLA по Hardware-тикетам и причин затягивания.
|
||||||
|
- Knowledge base для топ-3 проблем Internet-категории.
|
||||||
|
|
||||||
|
| category | opened | resolved | avg_resolution_h |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Internet | 2104 | 1987 | 4.2 |
|
||||||
|
| IPTV | 842 | 801 | 6.8 |
|
||||||
|
| Billing | 721 | 712 | 2.1 |
|
||||||
|
| Mobile | 512 | 490 | 3.5 |
|
||||||
|
| Hardware | 342 | 301 | 18.4 |`,
|
||||||
|
|
||||||
|
'billing.invoices': (provider) => `## Аналитика по таблице \`billing.invoices\`
|
||||||
|
|
||||||
|
**Выводы:**
|
||||||
|
- Стабильный рост выручки: с **480.2 → 514.8 млн ₸** за 4 месяца (+7.2%).
|
||||||
|
- Доля просрочек колеблется 3.7-6.2% — в марте всплеск (256 invoices), требует разбора.
|
||||||
|
- Conversion paid/issued выше 95% во всех месяцах — биллинг здоров.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
- Расследовать причины мартовского всплеска просрочек (массовая корпоративная задержка? технический баг?).
|
||||||
|
- Удержать темп +1.5% MoM по выручке через апсейл.
|
||||||
|
|
||||||
|
| month | issued | paid | overdue | total_kzt |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 2026-01 | 4012 | 3812 | 200 | 480 200 000 |
|
||||||
|
| 2026-02 | 4087 | 3901 | 186 | 491 400 000 |
|
||||||
|
| 2026-03 | 4133 | 3877 | 256 | 502 100 000 |
|
||||||
|
| 2026-04 | 4201 | 4044 | 157 | 514 800 000 |`,
|
||||||
|
|
||||||
|
'network.incidents': (provider) => `## Аналитика по таблице \`network.incidents\`
|
||||||
|
|
||||||
|
**Выводы:**
|
||||||
|
- **P1 Critical**: всего 8 случаев, но затронули 12 400 пользователей — высокий blast radius.
|
||||||
|
- **MTTR растёт по убыванию severity** (42 → 320 мин), что нормально, но **P4 320 мин** избыточно для low-severity.
|
||||||
|
- P3 Medium — самая частая категория по unique-инцидентам (102), но средний MTTR 3+ часа.
|
||||||
|
|
||||||
|
**Рекомендации:**
|
||||||
|
- Автоматизация runbook'ов для P3/P4 — снизит MTTR.
|
||||||
|
- Post-mortem по всем P1 инцидентам с фокусом на сокращение affected users.
|
||||||
|
|
||||||
|
| severity | count | mttr_minutes | affected_users |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 Critical | 8 | 42 | 12 400 |
|
||||||
|
| P2 High | 34 | 87 | 3 210 |
|
||||||
|
| P3 Medium | 102 | 184 | 640 |
|
||||||
|
| P4 Low | 143 | 320 | 54 |`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildAnalysis(table, provider, userPrompt) {
|
||||||
|
const baseAnalysis = MOCK_ANALYSES[table]
|
||||||
|
? MOCK_ANALYSES[table](provider)
|
||||||
|
: `## Аналитика по таблице \`${table}\`\n\n*Mock-данные для этой таблицы не настроены.*`;
|
||||||
|
|
||||||
|
if (userPrompt && userPrompt.trim().length > 0) {
|
||||||
|
// Имитируем, что LLM учёл кастомный промпт
|
||||||
|
return `> **Использован кастомный промпт:** _"${userPrompt.trim().slice(0, 120)}${userPrompt.length > 120 ? '...' : ''}"_\n\n${baseAnalysis}`;
|
||||||
|
}
|
||||||
|
return baseAnalysis;
|
||||||
|
}
|
||||||
344
styles.css
Normal file
344
styles.css
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--panel: #1a2028;
|
||||||
|
--panel-2: #232b35;
|
||||||
|
--border: #2d3742;
|
||||||
|
--text: #e6e9ee;
|
||||||
|
--text-dim: #8a96a8;
|
||||||
|
--accent: #4a9eff;
|
||||||
|
--accent-hover: #6db1ff;
|
||||||
|
--success: #4ade80;
|
||||||
|
--warn: #fbbf24;
|
||||||
|
--error: #f87171;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
|
||||||
|
.subtitle { margin: 0; color: var(--text-dim); font-size: 12px; }
|
||||||
|
|
||||||
|
.env-badge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.env-label { color: var(--text-dim); }
|
||||||
|
.env-value { color: var(--accent); }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.col { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-row { margin-bottom: 14px; }
|
||||||
|
.form-row label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
input[type=text], input[type=password], textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
textarea { font-family: var(--mono); font-size: 12px; resize: vertical; }
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
input:disabled, textarea:disabled, select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint { color: var(--text-dim); font-size: 11px; display: block; margin-top: 4px; }
|
||||||
|
.hint code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* VPN status */
|
||||||
|
.vpn-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
transition: background 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
.status-dot.connecting { background: var(--warn); animation: pulse 1.2s infinite; }
|
||||||
|
.status-dot.up { background: var(--success); box-shadow: 0 0 8px rgba(74, 222, 128, 0.5); }
|
||||||
|
.status-dot.failed { background: var(--error); }
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { background: var(--border); }
|
||||||
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.big {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
button.link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.actions button { flex: 1; }
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.checkbox input { margin: 0; }
|
||||||
|
|
||||||
|
/* Radio group */
|
||||||
|
.radio-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.radio:has(input:checked) { border-color: var(--accent); }
|
||||||
|
.radio input { margin: 0; }
|
||||||
|
.radio small { color: var(--text-dim); font-family: var(--mono); font-size: 11px; margin-left: auto; }
|
||||||
|
|
||||||
|
/* Log */
|
||||||
|
.log-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pi-id { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
||||||
|
.log {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.log:empty::before {
|
||||||
|
content: "Лог BPMN-процесса появится здесь...";
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.log-line { display: block; }
|
||||||
|
.log-line .ts { color: var(--text-dim); }
|
||||||
|
.log-line.req { color: var(--accent); }
|
||||||
|
.log-line.res { color: #c4b5fd; }
|
||||||
|
.log-line.evt { color: var(--warn); }
|
||||||
|
.log-line.ok { color: var(--success); }
|
||||||
|
.log-line.err { color: var(--error); }
|
||||||
|
.log-line.info { color: var(--text); }
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
.result {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
min-height: 200px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.result.placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.result h1, .result h2, .result h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.result h1 { font-size: 18px; }
|
||||||
|
.result h2 { font-size: 16px; }
|
||||||
|
.result h3 { font-size: 14px; }
|
||||||
|
.result h1:first-child, .result h2:first-child, .result h3:first-child { margin-top: 0; }
|
||||||
|
.result p { margin: 8px 0; }
|
||||||
|
.result ul, .result ol { padding-left: 22px; margin: 8px 0; }
|
||||||
|
.result li { margin: 4px 0; }
|
||||||
|
.result strong { color: var(--accent); }
|
||||||
|
.result code {
|
||||||
|
background: var(--panel-2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.result table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.result th, .result td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.result th {
|
||||||
|
background: var(--panel-2);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.result tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner overlay on running */
|
||||||
|
.running .num { animation: spin-num 2s linear infinite; }
|
||||||
|
@keyframes spin-num {
|
||||||
|
0%, 100% { background: var(--panel-2); color: var(--accent); }
|
||||||
|
50% { background: var(--accent); color: white; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user