feat: add FastAPI backend with real Impala integration + Gitea Pages guide

- server.py: FastAPI с эндпоинтами /api/tables, /api/query, /api/ping
- Impala connect via impyla (LDAP + SSL), failover между bdas-worker-08 и bdas-utility-01
- Поле host_override для случаев когда DNS не резолвит hostname
- app.js: реальные fetch к /api/* с fallback на mock-данные при ошибке
- Поле IP-адреса хоста и БД по умолчанию = Impala
- Исправлен баг с fetch URL (без ведущего /) для работы через VS Code proxy
- GITEA.md: гайд по деплою через Gitea Pages
- .gitignore: исключение JDBC zip-архивов
- Удалён ZORRO.md (устарел)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kyrykbaev_i 2026-05-18 20:16:39 +05:00
parent 4e076ffd26
commit acaf019999
7 changed files with 434 additions and 751 deletions

4
.gitignore vendored
View File

@ -26,6 +26,10 @@ target/
*.db
*.log
# large binaries (JDBC drivers и подобное)
*.zip
*.jar
# OS / IDE
.DS_Store
.claude/

1
GITEA.md Symbolic link
View File

@ -0,0 +1 @@
/Users/timur/Dev/jigit/GITEA.md

598
ZORRO.md
View File

@ -1,598 +0,0 @@
# ZorroBPM Development Guide
Единый справочник для разработки процессов на ZorroBPM.
---
## Содержание
- [Архитектура](#архитектура)
- [Окружения](#окружения)
- [Локальный запуск](#локальный-запуск)
- [Docker Compose — полный стек](#docker-compose--полный-стек)
- [Docker Compose — только фронт (dev)](#docker-compose--только-фронт-dev)
- [Docker Compose — фронт + воркеры](#docker-compose--фронт--воркеры)
- [API Reference](#api-reference)
- [RabbitMQ и внешние воркеры](#rabbitmq-и-внешние-воркеры)
- [Разработка процессов](#разработка-процессов)
- [Тестирование](#тестирование)
- [Примеры](#примеры)
- [Известные ограничения](#известные-ограничения)
---
## Архитектура
```
┌──────────┐ ┌──────────────┐ ┌────────────┐
│ UI │────▶│ REST API │────▶│ PostgreSQL │
│ (nginx) │ │ (Spring Boot)│ │ │
└──────────┘ └──────┬───────┘ └────────────┘
│
▼
┌──────────────┐ ┌────────────────┐
│ RabbitMQ │◀───▶│ External │
│ │ │ Workers (опц.) │
└──────────────┘ └────────────────┘
```
| Компонент | Образ | Описание |
|-----------|-------|----------|
| **api** | `zorrodev/zorrobpm-rest` | BPM движок. Управляет определениями, инстансами, задачами, инцидентами |
| **ui** | `zorrodev/zorrobpm-ui` | Nginx SPA — мониторинг процессов, задач, деплоев |
| **db** | `postgres:18` | Хранит определения, инстансы, переменные, инциденты |
| **rabbitmq** | `rabbitmq:4.0.7-management` | Очереди для serviceTask и событий жизненного цикла |
---
## Окружения
| Окружение | URL | Примечание |
|-----------|-----|------------|
| **LOCAL** | http://localhost:9092 | Docker Compose |
| **PROD** | https://api.bpms.telecom.kz | Через VPN |
| **DEV Studio** | https://studio.zorrobpm.dev | Визуальный редактор |
| **DEV Station** | https://station.zorrobpm.dev | Мониторинг |
| **DEMO (m11s)** | https://api.m11s.zorrobpm.dev | Микросервисная демо-среда |
Swagger UI:
- Local: http://localhost:9092/swagger-ui/index.html
- Demo: https://api.m11s.zorrobpm.dev/swagger-ui/index.html
Аутентификация: не требуется (dev-окружение).
---
## Локальный запуск
```bash
# Запуск полного стека
docker compose -f docker-compose.yaml --env-file .env up -d
# Логи API
docker compose logs api -f
# Остановка
docker compose down
# Остановка + удаление данных
docker compose down -v
```
---
## Docker Compose — полный стек
Полный стек для самостоятельного запуска движка. Используй только если нужен свой локальный инстанс BPM.
```yaml
# docker-compose.yaml — полный стек ZorroBPM
services:
api:
image: zorrodev/zorrobpm-rest
environment:
- DB_URL=jdbc:postgresql://db/${DB_NAME}
- DB_USERNAME=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- APP_FILES_DIR=/files
- RABBITMQ_HOST=rabbitmq
- RABBITMQ_USERNAME=${RABBITMQ_USER}
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
- RABBITMQ_PORT=5672
volumes:
- files:/files
ports:
- 9092:80
db:
image: postgres:18
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- db:/var/lib/postgresql/18/docker
ports:
- "0.0.0.0:5435:5432"
ui:
image: zorrodev/zorrobpm-ui
ports:
- 9091:80
environment:
- ZORROBPM_API_URL=http://zorrobpm.local
rabbitmq:
image: rabbitmq:4.0.7-management
restart: unless-stopped
environment:
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
volumes:
- rabbitmq-data:/var/lib/rabbitmq
volumes:
rabbitmq-data:
db:
files:
```
`.env`:
```env
DB_NAME=zorrobpm
DB_USER=zorrobpm
DB_PASSWORD=zorrobpm
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
```
---
## Docker Compose — только фронт (dev)
**Для разработки процессов без локального движка.** Подключается к удалённому API (demo, dev или prod). Не нужны PostgreSQL, RabbitMQ, API — только UI.
```yaml
# docker-compose.front.yaml — фронт без движка
services:
ui:
image: zorrodev/zorrobpm-ui
ports:
- 9091:80
environment:
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
```
`.env.front`:
```env
# Указать один из:
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev # demo
# ZORROBPM_API_URL=https://api.bpms.telecom.kz # prod (VPN)
# ZORROBPM_API_URL=http://localhost:9092 # local
```
```bash
docker compose -f docker-compose.front.yaml --env-file .env.front up -d
```
---
## Docker Compose — фронт + воркеры
**Когда процесс содержит serviceTask с внешними воркерами.** Фронт для мониторинга + RabbitMQ для получения задач + твой воркер.
```yaml
# docker-compose.workers.yaml — фронт + RabbitMQ воркеры
services:
ui:
image: zorrodev/zorrobpm-ui
ports:
- 9091:80
environment:
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
# Пример Java-воркера
worker:
build: ./worker
environment:
- ZORROBPM_API_URL=${ZORROBPM_API_URL}
- RABBITMQ_HOST=${RABBITMQ_HOST}
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
- RABBITMQ_USERNAME=${RABBITMQ_USER}
- RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
depends_on:
- ui
```
`.env.workers`:
```env
ZORROBPM_API_URL=https://api.m11s.zorrobpm.dev
# RabbitMQ — указать хост удалённого брокера, если он открыт,
# или поднять локально (добавить rabbitmq сервис)
RABBITMQ_HOST=rabbitmq.m11s.zorrobpm.dev
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
```
> **Примечание:** если удалённый RabbitMQ недоступен извне, добавь сервис `rabbitmq` из полного стека и настрой API на использование этого брокера.
---
## API Reference
Base URL: `http://localhost:9092` (local) или `https://api.m11s.zorrobpm.dev` (demo)
### Process Definitions
| Метод | Endpoint | Описание |
|-------|----------|----------|
| `POST` | `/process-definitions` | Деплой BPMN |
| `GET` | `/process-definitions` | Список (с пагинацией) |
| `GET` | `/process-definitions/{id}` | По ID |
| `GET` | `/process-definitions/{id}/xml` | BPMN XML |
**Деплой:**
```http
POST /process-definitions
Content-Type: application/json
{ "bpmn": "<bpmn:definitions>...</bpmn:definitions>" }
```
Ответ — `ProcessDefinition`:
```json
{
"id": "uuid",
"key": "string",
"version": 1,
"name": "string",
"sha256": "string",
"createdAt": "date-time",
"startFormKey": "string"
}
```
Query params: `pageIndex`, `pageSize`, `processDefinitionKey`, `processDefinitionVersion`, `latestVersionOnly`
---
### Process Instances
| Метод | Endpoint | Описание |
|-------|----------|----------|
| `POST` | `/process-instances` | Запуск инстанса |
| `GET` | `/process-instances` | Список (с пагинацией) |
**Запуск:**
```http
POST /process-instances
Content-Type: application/json
{
"processDefinitionId": "uuid",
"processDefinitionKey": "string",
"processDefinitionVersion": 1,
"variables": [
{ "name": "var1", "value": "val1", "type": "STRING" }
]
}
```
Можно указать `processDefinitionId` ИЛИ `processDefinitionKey` + `processDefinitionVersion`.
Ответ: `{ "id": "uuid" }`
Query params: `id`, `pageIndex`, `pageSize`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`
Объект `ProcessInstance`:
```json
{
"id": "uuid",
"parentActivityId": "uuid",
"processDefinitionId": "uuid",
"startedAt": "date-time",
"completedAt": "date-time"
}
```
---
### Service Tasks
| Метод | Endpoint | Описание |
|-------|----------|----------|
| `GET` | `/service-tasks` | Список сервисных задач |
| `POST` | `/service-tasks/{id}/complete` | Завершить задачу |
Query params: `id`, `jobType`, `completed`, `pageIndex`, `pageSize`, `processInstanceId`
Объект `ServiceTask`:
```json
{
"id": "uuid",
"code": "string",
"name": "string",
"processInstanceId": "uuid",
"processDefinitionId": "uuid",
"job": "string",
"createdAt": "date-time",
"completedAt": "date-time"
}
```
**Завершение:**
```http
POST /service-tasks/{id}/complete
Content-Type: application/json
{
"variables": [
{ "name": "result", "value": "done", "type": "STRING" }
]
}
```
---
### User Tasks
| Метод | Endpoint | Описание |
|-------|----------|----------|
| `GET` | `/user-tasks` | Список пользовательских задач |
| `POST` | `/user-tasks/{id}/complete` | Завершить задачу |
Query params: `id`, `assignee`, `candidateGroup`, `candidateUser`, `completed`, `assigned`, `pageIndex`, `pageSize`, `processInstanceId`
Объект `UserTask`:
```json
{
"id": "uuid",
"code": "string",
"name": "string",
"processInstanceId": "uuid",
"processDefinitionId": "uuid",
"formKey": "string",
"createdAt": "date-time",
"completedAt": "date-time"
}
```
**Завершение:**
```http
POST /user-tasks/{id}/complete
Content-Type: application/json
{
"variables": [
{ "name": "action", "value": "approve", "type": "STRING" }
]
}
```
---
### Incidents
| Метод | Endpoint | Описание |
|-------|----------|----------|
| `GET` | `/incidents` | Список инцидентов |
| `POST` | `/incidents/{id}/resolve` | Разрешить инцидент |
Query params: `id`, `processInstanceId`, `processDefinitionId`, `processDefinitionKey`, `processDefinitionVersion`, `bpmnElementId`, `pageIndex`, `pageSize`
Объект `Incident`:
```json
{
"id": "uuid",
"activityId": "uuid",
"message": "string",
"createdAt": "date-time",
"completedAt": "date-time"
}
```
**Разрешение:**
```http
POST /incidents/{id}/resolve
Content-Type: application/json
{
"id": "uuid",
"variables": [
{ "name": "fix", "value": "retried", "type": "STRING" }
]
}
```
---
### Типы переменных
`STRING`, `UUID`, `LONG`, `BOOLEAN`
### Формат пагинации
Все list-эндпоинты возвращают:
```json
{
"pageIndex": 0,
"pageSize": 20,
"totalElements": 100,
"data": [...]
}
```
---
## RabbitMQ и внешние воркеры
### Очереди
| Очередь | Описание |
|---------|----------|
| `zorrobpm.engine.definition-events` | Жизненный цикл определений (deployed, updated) |
| `zorrobpm.engine.runtime-events` | Жизненный цикл инстансов (created, completed) и задач |
| `zorrobpm.jobs.<job_type>` | Очередь для каждого типа serviceTask |
### Как это работает
Когда BPMN `serviceTask` с атрибутом `job="srv_child_work"` достигается движком:
1. Движок публикует сообщение в очередь `zorrobpm.jobs.srv_child_work`
2. Внешний воркер читает из этой очереди
3. Воркер выполняет работу
4. Воркер вызывает `POST /service-tasks/{id}/complete`
### Пример воркера (Java + Spring AMQP)
```java
@RabbitListener(queues = "zorrobpm.jobs.srv_child_work")
public void handleChildWork(ServiceTaskMessage msg) {
// выполнить работу...
api.completeServiceTask(msg.getTaskId());
}
```
### Инспекция очередей
```bash
docker exec zorrobpm-rabbitmq-1 rabbitmqctl list_queues name messages consumers
```
---
## Разработка процессов
### Быстрый старт
1. **Нарисуй BPMN** — в Studio (https://studio.zorrobpm.dev) или любом BPMN-редакторе
2. **Деплой** — `POST /process-definitions` с BPMN XML
3. **Запусти** — `POST /process-instances` с ключом или ID определения
4. **Обрабатывай задачи**:
- `serviceTask` — воркер через RabbitMQ или ручной `POST /service-tasks/{id}/complete`
- `userTask` — UI или ручной `POST /user-tasks/{id}/complete`
### Выбор docker-compose
```
Нужен ли локальный BPM движок?
├── Да → docker-compose.yaml (полный стек)
└── Нет, использую удалённый API
├── Есть serviceTask с воркерами? → docker-compose.workers.yaml
└── Только UI/мониторинг? → docker-compose.front.yaml
```
### Структура проекта (рекомендуемая)
```
my-process/
├── docker-compose.yaml # выбери нужный вариант
├── .env
├── bpmn/
│ ├── myProcess.bpmn
│ └── childProcess.bpmn
├── worker/ # если есть serviceTask
│ ├── Dockerfile
│ └── src/
└── tests/
└── e2e/
```
---
## Тестирование
### Dev-контейнер
```yaml
# docker-compose.dev.yaml
services:
app:
image: maven:3.9-eclipse-temurin-17
container_name: examples-dev
working_dir: /app
volumes:
- .:/app
- maven-cache:/root/.m2
ports:
- "8080:8080"
- "5005:5005" # remote debug
command: sleep infinity
volumes:
maven-cache:
```
### Режимы тестирования
| Режим | Команда | Зависимости | Описание |
|-------|---------|-------------|----------|
| **embedded** | `./test.sh` | Только dev контейнер | Unit тесты, встроенный движок + H2 |
| **local** | `./test.sh local` | Dev + ZorroBPM docker | E2E против `localhost:9092` |
| **remote** | `./test.sh remote` | Dev + интернет | E2E против `api.m11s.zorrobpm.dev` |
| **custom** | `./test.sh http://my-server:8080` | Dev + сервер | E2E против произвольного URL |
```bash
docker compose -f docker-compose.dev.yaml up -d
./test.sh # embedded
./test.sh local # E2E local
./test.sh remote # E2E remote
```
---
## Примеры
### call-activity — вызов дочернего процесса
**Родительский процесс** (`parentProcess.bpmn`):
```
[Start] → [CallActivity: childProcess] → [End]
```
**Дочерний процесс** (`childProcess.bpmn`):
```
[Start] → [ServiceTask: srv_child_work] → [End]
```
**Поток выполнения:**
1. Деплой обоих BPMN
2. Запуск родительского — он входит в `callActivity`, создаёт дочерний инстанс
3. Дочерний работает в **отдельном processInstance**
4. Завершение serviceTask в дочернем → callActivity возобновляется → родитель завершается
**Файлы:**
```
src/main/resources/examples/call-activity/
parentProcess.bpmn
childProcess.bpmn
src/test/java/.../examples/callactivity/
CallActivityTest.java # unit (embedded)
src/test/java/.../e2e/
CallActivityE2ETest.java # E2E
ZorroWebAPI.java # HTTP клиент
```
---
## Известные ограничения
### Нет parentProcessInstanceId
API не поддерживает фильтр `parentProcessInstanceId` в `ProcessInstance`. Поле `parentActivityId` — это UUID активити (callActivity элемента), а не ID родительского инстанса.
**Нельзя напрямую запросить:** "дай все дочерние процессы родителя X".
**Workaround:** найти последний инстанс с `parentActivityId != null` на последней странице. Работает для последовательных тестов, ненадёжно при параллельных.
**Желаемое улучшение API:**
```
GET /process-instances?parentProcessInstanceId={uuid}
```
Плюс добавить поле `parentProcessInstanceId` в объект `ProcessInstance`.

369
app.js
View File

@ -1,33 +1,69 @@
// MVP логика: эмулирует поток GUI → Zorro BPMN → Worker → Result.
// Все сетевые вызовы заменены на mockApi.* с реалистичными задержками.
// Структура и имена эндпоинтов соответствуют TRD.md §6 и ZORRO.md §API Reference.
// GUI логика: Zorro BPMN мокирован, Impala — реальный API (/api/tables, /api/query).
// Структура эндпоинтов по TRD.md §6 и ZORRO.md §API Reference.
const state = {
vpnUp: false,
running: false,
vpnPiId: null,
impalaConnected: false,
};
// ─── Mock Zorro API ────────────────────────────────────────────────────────────
// ─── Real Impala API ───────────────────────────────────────────────────────────
const mockApi = {
// POST /process-instances → {id}
// Базовый путь для fetch — работает и через VS Code proxy (/proxy/PORT/),
// и напрямую (localhost:PORT). Относительный путь без ведущего / прилипает к текущему origin+path.
const API_BASE = 'api';
const impalaApi = {
async tables(user, password, database, host_override = '') {
const r = await fetch(`${API_BASE}/tables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, password, database, host_override }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(err.detail || r.statusText);
}
return r.json();
},
async query(user, password, database, table, limit = 500, host_override = '') {
const r = await fetch(`${API_BASE}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, password, database, table, limit, host_override }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(err.detail || r.statusText);
}
return r.json();
},
};
// ─── Mock Zorro BPMN API ───────────────────────────────────────────────────────
const mockZorro = {
startProcess(processDefinitionKey, variables) {
const piId = 'pi-' + crypto.randomUUID().slice(0, 8);
logReq(`POST /process-instances`, { processDefinitionKey, variables });
return delay(180).then(() => {
return delay(120).then(() => {
logRes(`200 OK { id: "${piId}" }`);
return { id: piId };
});
},
// Эмуляция шагов serviceTask: задержка → log "complete"
serviceTaskStep(piId, code, durationMs, outputs = {}) {
logEvt(`⏵ ServiceTask started: ${code} (job=${code})`);
logEvt(`⏵ ServiceTask: ${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(() => {
const safeOutputs = { ...outputs };
// не логируем большие данные
if (safeOutputs.dataMarkdown && safeOutputs.dataMarkdown.length > 80) {
safeOutputs.dataMarkdown = safeOutputs.dataMarkdown.slice(0, 80) + '…';
}
logReq(`POST /service-tasks/${taskId}/complete`, { variables: safeOutputs });
return delay(60).then(() => {
logRes(`200 OK`);
logOk(`✓ ServiceTask completed: ${code}`);
return outputs;
@ -35,149 +71,205 @@ const mockApi = {
});
},
// Финал: процесс завершён
finishProcess(piId) {
logInfo(`processInstance ${piId} → completedAt set`);
logOk(`✓ Process completed`);
},
};
// ─── Mock VPN (через BPMN vpnConnect) ──────────────────────────────────────────
// ─── Impala connect (кнопка "Загрузить таблицы") ──────────────────────────────
async function connectImpala() {
const user = $('#impala-user').value.trim();
const pass = $('#impala-pass').value;
const db = $('#impala-db').value.trim() || 'default';
const ip = $('#impala-ip').value.trim();
if (!user || !pass) { alert('Введите пользователя и пароль Impala'); return; }
setImpalaStatus('busy', 'Подключение...');
$('#impala-connect').disabled = true;
try {
logInfo(`Impala: SHOW TABLES IN ${db} · хост: ${ip || 'bdas-worker-08 (hostname)'}`);
const data = await impalaApi.tables(user, pass, db, ip);
logOk(`✓ Impala connected: ${data.host} → ${data.tables.length} таблиц`);
const sel = $('#table-select');
sel.innerHTML = '<option value="">— выберите таблицу —</option>';
data.tables.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = `${db}.${t}`;
sel.appendChild(opt);
});
sel.disabled = false;
$('#table-hint').innerHTML = `✓ ${data.tables.length} таблиц из <code>${db}</code> · хост: ${data.host}`;
$('#run-btn').disabled = false;
state.impalaConnected = true;
setImpalaStatus('ok', `${data.host}`);
} catch (e) {
logErr(`✗ Impala: ${e.message}`);
setImpalaStatus('err', e.message.slice(0, 80));
// Fallback на моковые данные
logInfo('Fallback: загружаем mock-таблицы для демо');
populateMockTables();
} finally {
$('#impala-connect').disabled = false;
}
}
function populateMockTables() {
const sel = $('#table-select');
sel.innerHTML = '<option value="">— выберите таблицу (mock) —</option>';
MOCK_TABLES.forEach(t => {
const opt = document.createElement('option');
opt.value = t.name;
opt.textContent = `${t.name} [mock · ~${t.rows.toLocaleString()} строк]`;
sel.appendChild(opt);
});
sel.disabled = false;
$('#run-btn').disabled = false;
$('#table-hint').innerHTML = `<span style="color:var(--warn)">⚠ Mock-данные (Impala недоступна)</span>`;
}
function setImpalaStatus(type, text) {
// Удаляем предыдущий badge если есть
$('#impala-creds .impala-status')?.remove();
const badge = document.createElement('div');
badge.className = `impala-status ${type}`;
badge.textContent = text;
$('#impala-creds').prepend(badge);
}
// ─── VPN (мок через BPMN vpnConnect) ──────────────────────────────────────────
async function connectVpn() {
const gateway = $('#vpn-gateway').value.trim();
const user = $('#vpn-user').value.trim();
const pass = $('#vpn-pass').value;
const user = $('#vpn-user').value.trim();
const pass = $('#vpn-pass').value;
if (!user || !pass) {
alert('Введите username и password Checkpoint VPN');
return;
}
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', [
const pi = await mockZorro.startProcess('vpnConnect', [
{ name: 'gateway', value: gateway, type: 'STRING' },
{ name: 'username', value: user, type: 'STRING' },
{ name: 'password', value: '***', type: 'STRING' }, // в реале — тоже в STRING, но не логируем
{ name: 'password', value: '***', type: 'STRING' },
]);
state.vpnPiId = pi.id;
setPiId(pi.id);
await mockApi.serviceTaskStep(pi.id, 'srv_vpn_connect', 1400, {
vpnStatus: 'up',
});
mockApi.finishProcess(pi.id);
await mockZorro.serviceTaskStep(pi.id, 'srv_vpn_connect', 900, { vpnStatus: 'up' });
mockZorro.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}`);
logErr(`✗ VPN: ${e.message}`);
}
}
async function disconnectVpn() {
$('#vpn-disconnect').disabled = true;
setVpnStatus('connecting', 'Disconnecting...');
const pi = await mockApi.startProcess('vpnDisconnect', []);
const pi = await mockZorro.startProcess('vpnDisconnect', []);
setPiId(pi.id);
await mockApi.serviceTaskStep(pi.id, 'srv_vpn_disconnect', 500, { vpnStatus: 'down' });
mockApi.finishProcess(pi.id);
await mockZorro.serviceTaskStep(pi.id, 'srv_vpn_disconnect', 400, { vpnStatus: 'down' });
mockZorro.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)');
setVpnStatus('up', 'Skipped (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)');
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) ────────────────────────────────
// ─── Run analysis (BPMN dailyAnalytics) ───────────────────────────────────────
async function runAnalysis() {
const table = $('#table-select').value;
const table = $('#table-select').value;
const userPrompt = $('#prompt-input').value.trim();
const provider = document.querySelector('input[name=llm]:checked').value;
const provider = document.querySelector('input[name=llm]:checked').value;
const isMock = table && MOCK_TABLES.find(t => t.name === table);
const impalaUser = $('#impala-user').value.trim();
const impalaPass = $('#impala-pass').value;
const impalaDb = $('#impala-db').value.trim() || 'default';
if (!table) { alert('Выберите таблицу'); return; }
state.running = true;
$('#run-btn').disabled = true;
$('#run-btn').textContent = '⏳ Запущено...';
$('#run-btn').textContent = '⏳ Выполняется...';
$('#result').classList.remove('placeholder');
$('#result').textContent = 'Выполняется BPMN-процесс dailyAnalytics...';
$('#result').innerHTML = '<em style="color:var(--text-dim)">Запуск BPMN dailyAnalytics...</em>';
$('#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' },
const pi = await mockZorro.startProcess('dailyAnalytics', [
{ name: 'table', value: table, type: 'STRING' },
{ name: 'userPrompt', value: userPrompt, type: 'STRING' },
{ name: 'llmProvider', value: provider, type: 'STRING' },
{ name: 'requestedBy', value: impalaUser || '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 1: srv_impala_query ──
let queryResult;
if (isMock || !impalaUser || !impalaPass) {
// Mock fallback
logInfo(`srv_impala_query: mock-данные для ${table}`);
const qd = MOCK_QUERY_RESULTS[table] || { columns: ['col'], rows: [['нет данных']] };
queryResult = { columns: qd.columns, rows: qd.rows, row_count: qd.rows.length, sql: `SELECT * FROM ${table} LIMIT 500`, host: 'mock' };
await mockZorro.serviceTaskStep(pi.id, 'srv_impala_query', 600, {
rowCount: queryResult.row_count, dataMarkdown: '…',
});
} else {
// Реальный запрос к Impala
const tableShort = table.includes('.') ? table.split('.').pop() : table;
logInfo(`srv_impala_query: SELECT * FROM ${impalaDb}.${tableShort} LIMIT 500`);
queryResult = await impalaApi.query(impalaUser, impalaPass, impalaDb, tableShort, 500);
logOk(`✓ Impala: ${queryResult.row_count} строк из ${queryResult.host}`);
await mockZorro.serviceTaskStep(pi.id, 'srv_impala_query', 200, {
rowCount: queryResult.row_count, dataMarkdown: '…',
});
}
// 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: '<...>',
});
const dataMarkdown = rowsToMarkdown(queryResult);
mockApi.finishProcess(pi.id);
// ── Step 2: srv_llm_analyze (мок) ──
logInfo(`srv_llm_analyze: ${provider === 'ollama' ? 'Ollama llama3.1:8b' : 'Anthropic claude-sonnet-4-6'} [мок]`);
let resultMarkdown;
if (isMock && MOCK_ANALYSES[table]) {
resultMarkdown = buildAnalysis(table, provider, userPrompt);
} else {
// Генерируем базовый анализ из реальных данных
resultMarkdown = buildGenericAnalysis(table, queryResult, provider, userPrompt);
}
await mockZorro.serviceTaskStep(pi.id, 'srv_llm_analyze', 800, { resultMarkdown: '…' });
mockZorro.finishProcess(pi.id);
// Render result
$('#result').innerHTML = renderMarkdown(resultMarkdown);
} catch (e) {
$('#result').innerHTML = `<p style="color: var(--error);">❌ ${e.message}</p>`;
$('#result').innerHTML = `<p style="color:var(--error)">❌ ${escapeHtml(e.message)}</p>`;
logErr(`✗ ${e.message}`);
} finally {
state.running = false;
@ -187,34 +279,34 @@ async function runAnalysis() {
}
}
// Генерирует шаблонный анализ из реальных данных Impala (LLM мокирован)
function buildGenericAnalysis(tableName, qr, provider, userPrompt) {
const colList = qr.columns.join(', ');
const note = provider === 'ollama' ? 'llama3.1:8b [мок]' : 'claude-sonnet-4-6 [мок]';
let md = `## Аналитика: \`${tableName}\`\n`;
if (userPrompt) md += `> **Промпт:** _"${userPrompt.slice(0, 120)}"_\n\n`;
md += `**Источник данных (${note}):**\n`;
md += `- Строк загружено: **${qr.row_count}** (LIMIT 500)\n`;
md += `- Колонки: \`${colList}\`\n`;
md += `- Хост: \`${qr.host}\`\n\n`;
md += `> ⚠ LLM-анализ мокирован. Подключите реальный Ollama или Anthropic API для получения выводов.\n\n`;
md += rowsToMarkdown(qr);
return md;
}
// ─── 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-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}`;
}
function disableForm(ids) { ids.forEach(id => { const el = $('#' + id); if (el) el.disabled = true; }); }
function enableForm(ids) { ids.forEach(id => { const el = $('#' + id); if (el) el.disabled = false; }); }
function setPiId(id) { $('#pi-id').textContent = `processInstance: ${id}`; }
// ─── Logging ───────────────────────────────────────────────────────────────────
@ -232,30 +324,24 @@ function appendLog(cls, text, payload) {
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 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('—');
}
function clearLog() { $('#log').innerHTML = ''; setPiId('—'); }
// ─── Markdown renderer (minimal: headers, lists, bold, code, tables, blockquote) ──
// ─── Markdown renderer ─────────────────────────────────────────────────────────
function renderMarkdown(md) {
const lines = md.split('\n');
let html = '';
let inTable = false, tableRows = [];
let inList = false;
let html = '', inTable = false, tableRows = [], inList = false;
const flushTable = () => {
if (tableRows.length < 2) { tableRows = []; inTable = false; return; }
const header = tableRows[0];
const body = tableRows.slice(2); // skip |---|
const header = tableRows[0], body = tableRows.slice(2);
html += '<table><thead><tr>';
header.forEach(c => html += `<th>${inline(c)}</th>`);
html += '</tr></thead><tbody>';
@ -265,35 +351,25 @@ function renderMarkdown(md) {
html += '</tr>';
});
html += '</tbody></table>';
tableRows = [];
inTable = false;
tableRows = []; inTable = false;
};
const flushList = () => { if (inList) { html += '</ul>'; inList = false; } };
for (const raw of lines) {
const line = raw;
for (const line of lines) {
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);
flushList(); inTable = true;
tableRows.push(line.trim().split('|').slice(1, -1).map(s => s.trim()));
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 (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();
@ -308,18 +384,15 @@ function inline(s) {
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
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');
const sep = '| ' + qr.columns.map(() => '---').join(' | ') + ' |';
const body = qr.rows.slice(0, 100).map(r => '| ' + r.join(' | ') + ' |').join('\n');
const note = qr.rows.length > 100 ? `\n_...ещё ${qr.rows.length - 100} строк_` : '';
return [head, sep, body].join('\n') + note;
}
// ─── Init ──────────────────────────────────────────────────────────────────────
@ -330,8 +403,10 @@ window.addEventListener('DOMContentLoaded', () => {
$('#vpn-connect').addEventListener('click', connectVpn);
$('#vpn-disconnect').addEventListener('click', disconnectVpn);
$('#vpn-skip').addEventListener('change', onVpnSkipChange);
$('#impala-connect').addEventListener('click', connectImpala);
$('#run-btn').addEventListener('click', runAnalysis);
$('#clear-log').addEventListener('click', clearLog);
logInfo('GUI loaded. Mock Zorro API ready. См. TRD.md §9 для потока выполнения.');
logInfo('GUI ready. Impala API: /api/tables, /api/query. Zorro/LLM — мок.');
logInfo('Хосты: bdas-worker-08 / bdas-utility-01 · LDAP+SSL · port 21050');
});

View File

@ -11,7 +11,7 @@
<div class="header-inner">
<div>
<h1>Impala Analytics → Zorro BPMN → LLM</h1>
<p class="subtitle">MVP · все внешние вызовы мокированы · реализация по TRD.md</p>
<p class="subtitle">MVP · реализация по TRD.md</p>
</div>
<div class="env-badge">
<span class="env-label">ZORRO_API_URL</span>
@ -58,14 +58,39 @@
<!-- Query Panel -->
<section class="panel" id="query-panel">
<h2><span class="num">2</span> Запрос</h2>
<h2><span class="num">2</span> Impala — подключение и запрос</h2>
<div class="impala-creds" id="impala-creds">
<div class="form-row">
<label>Пользователь Impala</label>
<input type="text" id="impala-user" placeholder="i.kyrykbaev" autocomplete="off">
</div>
<div class="form-row">
<label>Пароль Impala</label>
<input type="password" id="impala-pass" autocomplete="off">
</div>
<div class="form-row" style="display:flex; gap:8px; align-items:flex-end;">
<div style="flex:1">
<label>База данных</label>
<input type="text" id="impala-db" value="Impala">
</div>
<button id="impala-connect" class="primary" style="margin-bottom:0; height:36px;">Загрузить таблицы</button>
</div>
<div class="form-row" style="margin-bottom:4px">
<label>IP-адрес хоста <span style="color:var(--text-dim)">(если DNS не резолвит hostname)</span></label>
<input type="text" id="impala-ip" placeholder="оставьте пустым — используется hostname" autocomplete="off">
</div>
<div class="impala-hosts">
<small>Хосты: bdas-worker-08 / bdas-utility-01 · порт 21050 · LDAP+SSL</small>
</div>
</div>
<div class="form-row">
<label>Таблица Impala</label>
<label>Таблица</label>
<select id="table-select" disabled>
<option value="">— подключитесь к VPN —</option>
<option value="">— введите учётные данные выше —</option>
</select>
<small class="hint" id="table-hint">Список загружается через <code>SHOW TABLES</code> после VPN</small>
<small class="hint" id="table-hint">Список загружается через <code>SHOW TABLES IN {database}</code></small>
</div>
<div class="form-row">
@ -108,7 +133,7 @@
</main>
<footer>
<small>kyrykbaev_i · MVP для хакатона · GUI = HTML/CSS/JS · Zorro/Impala/LLM/VPN мокированы для демо</small>
<small>kyrykbaev_i · MVP для хакатона · GUI = HTML/CSS/JS · VPN/Zorro/LLM мокированы · Impala — реальный</small>
</footer>
<script src="mock-data.js"></script>

146
server.py Normal file
View File

@ -0,0 +1,146 @@
"""
FastAPI-бэкенд MVP. Отдаёт статику (index.html/js/css) и API для Impala.
Запуск: python3 server.py
"""
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import traceback
# ─── Impala connection ─────────────────────────────────────────────────────────
IMPALA_HOSTS = [
"bdas-worker-08.bdpak.telecom.kz",
"bdas-utility-01.bdpak.telecom.kz",
]
IMPALA_PORT = 21050
def _impala_connect(user: str, password: str, host_override: str = ""):
"""
Подключается к Impala. Если host_override задан (IP или другой hostname),
пробует его первым — полезно когда DNS не резолвит внутренние имена.
AuthMech=3;UseSASL=1;SSL=1 → LDAP + SSL.
"""
from impala.dbapi import connect
hosts_to_try = ([host_override] if host_override else []) + IMPALA_HOSTS
last_err = None
for host in hosts_to_try:
try:
conn = connect(
host=host,
port=IMPALA_PORT,
auth_mechanism="LDAP",
use_ssl=True,
user=user,
password=password,
timeout=10,
)
return conn, host
except Exception as e:
last_err = e
raise ConnectionError(f"Не удалось подключиться. Пробовали: {hosts_to_try}. Ошибка: {last_err}")
# ─── Request/Response models ───────────────────────────────────────────────────
class ImpalaRequest(BaseModel):
user: str
password: str
database: Optional[str] = "default"
host_override: Optional[str] = "" # IP или hostname, если DNS не работает
class QueryRequest(ImpalaRequest):
table: str
limit: Optional[int] = 500
# ─── App & routes ──────────────────────────────────────────────────────────────
app = FastAPI(title="Impala Analytics MVP")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/ping")
def ping():
"""Проверка что бэкенд жив. Открыть: /api/ping"""
import socket
dns_ok = {}
for h in IMPALA_HOSTS:
try:
ip = socket.gethostbyname(h)
dns_ok[h] = ip
except Exception as e:
dns_ok[h] = f"FAIL: {e}"
return {"status": "ok", "impala_dns": dns_ok}
@app.post("/api/databases")
def list_databases(req: ImpalaRequest):
"""SHOW DATABASES — список доступных БД."""
try:
conn, host = _impala_connect(req.user, req.password, req.host_override)
cur = conn.cursor()
cur.execute("SHOW DATABASES")
dbs = [row[0] for row in cur.fetchall()]
cur.close(); conn.close()
return {"host": host, "databases": dbs}
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@app.post("/api/tables")
def list_tables(req: ImpalaRequest):
"""SHOW TABLES IN {database} — список таблиц."""
try:
conn, host = _impala_connect(req.user, req.password, req.host_override)
cur = conn.cursor()
cur.execute(f"SHOW TABLES IN `{req.database}`")
tables = [row[0] for row in cur.fetchall()]
cur.close(); conn.close()
return {"host": host, "database": req.database, "tables": tables}
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@app.post("/api/query")
def run_query(req: QueryRequest):
"""SELECT * FROM table LIMIT N — columns + rows. Лимит защищает от перегрузки LLM."""
safe_table = req.table.replace(";", "").replace("--", "")
sql = f"SELECT * FROM `{req.database}`.`{safe_table}` LIMIT {req.limit}"
try:
conn, host = _impala_connect(req.user, req.password, req.host_override)
cur = conn.cursor()
cur.execute(sql)
columns = [d[0] for d in cur.description]
rows = [list(row) for row in cur.fetchall()]
cur.close(); conn.close()
return {"host": host, "sql": sql, "columns": columns, "rows": rows, "row_count": len(rows)}
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@app.post("/api/sql")
def run_custom_sql(req: ImpalaRequest, sql: str):
"""Произвольный SELECT (для будущих кастомных запросов)."""
if not sql.strip().upper().startswith("SELECT"):
raise HTTPException(status_code=400, detail="Только SELECT")
try:
conn, host = _impala_connect(req.user, req.password)
cur = conn.cursor()
cur.execute(sql)
columns = [d[0] for d in cur.description]
rows = [list(row) for row in cur.fetchall()]
cur.close(); conn.close()
return {"host": host, "columns": columns, "rows": rows, "row_count": len(rows)}
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
# Статика — монтируется ПОСЛЕ роутов (FastAPI приоритизирует явные роуты)
app.mount("/", StaticFiles(directory=".", html=True), name="static")
# ─── Entrypoint ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
uvicorn.run("server:app", host="0.0.0.0", port=47821, reload=True)

View File

@ -208,6 +208,36 @@ button.link:hover { color: var(--accent); }
.actions { display: flex; gap: 8px; margin-bottom: 12px; }
.actions button { flex: 1; }
/* Impala credentials block */
.impala-creds {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
margin-bottom: 16px;
}
.impala-hosts {
margin-top: 6px;
color: var(--text-dim);
font-family: var(--mono);
font-size: 10.5px;
}
/* Impala connection status badge */
.impala-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 11px;
padding: 4px 10px;
border-radius: 20px;
margin-bottom: 10px;
}
.impala-status.ok { background: rgba(74,222,128,.15); color: var(--success); border: 1px solid rgba(74,222,128,.3); }
.impala-status.err { background: rgba(248,113,113,.15); color: var(--error); border: 1px solid rgba(248,113,113,.3); }
.impala-status.busy { background: rgba(251,191,36,.12); color: var(--warn); border: 1px solid rgba(251,191,36,.3); }
.checkbox {
display: flex;
align-items: center;