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:
parent
4e076ffd26
commit
acaf019999
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,6 +26,10 @@ target/
|
||||
*.db
|
||||
*.log
|
||||
|
||||
# large binaries (JDBC drivers и подобное)
|
||||
*.zip
|
||||
*.jar
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
||||
598
ZORRO.md
598
ZORRO.md
@ -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`.
|
||||
337
app.js
337
app.js
@ -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;
|
||||
|
||||
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 userPrompt = $('#prompt-input').value.trim();
|
||||
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', [
|
||||
const pi = await mockZorro.startProcess('dailyAnalytics', [
|
||||
{ name: 'table', value: table, type: 'STRING' },
|
||||
{ name: 'userPrompt', value: userPrompt || '', type: 'STRING' },
|
||||
{ name: 'userPrompt', value: userPrompt, type: 'STRING' },
|
||||
{ name: 'llmProvider', value: provider, type: 'STRING' },
|
||||
{ name: 'requestedBy', value: $('#vpn-user').value || 'anonymous', 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: '…',
|
||||
});
|
||||
|
||||
// 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: '<...>',
|
||||
} 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: '…',
|
||||
});
|
||||
}
|
||||
|
||||
mockApi.finishProcess(pi.id);
|
||||
const dataMarkdown = rowsToMarkdown(queryResult);
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -239,23 +331,17 @@ 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,18 +351,14 @@ 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();
|
||||
@ -284,16 +366,10 @@ function renderMarkdown(md) {
|
||||
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>`;
|
||||
}
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
37
index.html
37
index.html
@ -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
146
server.py
Normal 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)
|
||||
30
styles.css
30
styles.css
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user