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:
kyrykbaev_i 2026-05-18 13:03:39 +05:00
parent 0d2ab71165
commit 4e076ffd26
6 changed files with 1308 additions and 25 deletions

29
PRD.md
View File

@ -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
View File

@ -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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View 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
View 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
View 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; }
}