This repository has been archived on 2026-11-14. You can view files and clone it, but cannot push or open issues or pull requests.
Impala-connection/index.html
kyrykbaev_i d091fb973f build: inline css+js into index.html for Gitea Pages
Gitea Pages serves .css and .js as text/plain, which strict browsers
refuse to load. Inlining keeps the page self-contained and avoids the
MIME issue. Source files (styles.css, app.js, mock-data.js) remain
authoritative on main.
2026-05-19 09:59:56 +05:00

1150 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impala Analytics — Zorro BPMN MVP</title>
<style>
* { box-sizing: border-box; }
:root {
--bg: #0f1419;
--panel: #1a2028;
--panel-2: #232b35;
--border: #2d3742;
--text: #e6e9ee;
--text-dim: #8a96a8;
--accent: #4a9eff;
--accent-hover: #6db1ff;
--success: #4ade80;
--warn: #fbbf24;
--error: #f87171;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
}
h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
.subtitle { margin: 0; color: var(--text-dim); font-size: 12px; }
.env-badge {
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: var(--mono);
font-size: 11px;
}
.env-label { color: var(--text-dim); }
.env-value { color: var(--accent); }
main {
flex: 1;
max-width: 1400px;
margin: 0 auto;
padding: 24px;
width: 100%;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
gap: 20px;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
.col { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.panel h2 {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 10px;
}
.num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: var(--panel-2);
border-radius: 50%;
color: var(--accent);
font-size: 12px;
font-weight: 700;
}
/* Forms */
.form-row { margin-bottom: 14px; }
.form-row label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--text-dim); }
input[type=text], input[type=password], textarea, select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 8px 10px;
color: var(--text);
font-family: inherit;
font-size: 13px;
transition: border-color 0.15s;
}
textarea { font-family: var(--mono); font-size: 12px; resize: vertical; }
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
}
input:disabled, textarea:disabled, select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint { color: var(--text-dim); font-size: 11px; display: block; margin-top: 4px; }
.hint code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; font-size: 11px; }
/* VPN status */
.vpn-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--panel-2);
border-radius: 5px;
margin-bottom: 16px;
font-family: var(--mono);
font-size: 12px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-dim);
transition: background 0.3s, box-shadow 0.3s;
}
.status-dot.connecting { background: var(--warn); animation: pulse 1.2s infinite; }
.status-dot.up { background: var(--success); box-shadow: 0 0 8px rgba(74, 222, 128, 0.5); }
.status-dot.failed { background: var(--error); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Buttons */
button {
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
}
button:hover:not(:disabled) { background: var(--border); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
button.big {
width: 100%;
padding: 12px;
font-size: 14px;
font-weight: 600;
margin-top: 8px;
}
button.link {
background: none;
border: none;
color: var(--text-dim);
padding: 0;
font-size: 11px;
}
button.link:hover { color: var(--accent); }
.actions { display: flex; gap: 8px; margin-bottom: 12px; }
.actions button { flex: 1; }
/* 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;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
}
.checkbox input { margin: 0; }
/* Radio group */
.radio-group { display: flex; flex-direction: column; gap: 6px; }
.radio {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
font-size: 13px;
transition: border-color 0.15s;
}
.radio:has(input:checked) { border-color: var(--accent); }
.radio input { margin: 0; }
.radio small { color: var(--text-dim); font-family: var(--mono); font-size: 11px; margin-left: auto; }
/* Log */
.log-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.pi-id { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.log {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 12px;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
min-height: 180px;
max-height: 320px;
overflow-y: auto;
white-space: pre-wrap;
}
.log:empty::before {
content: "Лог BPMN-процесса появится здесь...";
color: var(--text-dim);
font-style: italic;
}
.log-line { display: block; }
.log-line .ts { color: var(--text-dim); }
.log-line.req { color: var(--accent); }
.log-line.res { color: #c4b5fd; }
.log-line.evt { color: var(--warn); }
.log-line.ok { color: var(--success); }
.log-line.err { color: var(--error); }
.log-line.info { color: var(--text); }
/* Result */
.result {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 18px 22px;
min-height: 200px;
font-size: 13.5px;
line-height: 1.65;
}
.result.placeholder {
color: var(--text-dim);
font-style: italic;
display: flex;
align-items: center;
justify-content: center;
}
.result h1, .result h2, .result h3 {
margin: 16px 0 8px;
color: var(--text);
}
.result h1 { font-size: 18px; }
.result h2 { font-size: 16px; }
.result h3 { font-size: 14px; }
.result h1:first-child, .result h2:first-child, .result h3:first-child { margin-top: 0; }
.result p { margin: 8px 0; }
.result ul, .result ol { padding-left: 22px; margin: 8px 0; }
.result li { margin: 4px 0; }
.result strong { color: var(--accent); }
.result code {
background: var(--panel-2);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--mono);
font-size: 12px;
}
.result table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 12.5px;
}
.result th, .result td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.result th {
background: var(--panel-2);
font-weight: 600;
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.result tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
footer {
text-align: center;
padding: 16px;
color: var(--text-dim);
font-size: 11px;
border-top: 1px solid var(--border);
}
/* Spinner overlay on running */
.running .num { animation: spin-num 2s linear infinite; }
@keyframes spin-num {
0%, 100% { background: var(--panel-2); color: var(--accent); }
50% { background: var(--accent); color: white; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div>
<h1>Impala Analytics → Zorro BPMN → LLM</h1>
<p class="subtitle">MVP · реализация по TRD.md</p>
</div>
<div class="env-badge">
<span class="env-label">ZORRO_API_URL</span>
<span class="env-value">https://api.m11s.zorrobpm.dev</span>
</div>
</div>
</header>
<main>
<div class="grid">
<div class="col">
<!-- VPN Panel -->
<section class="panel" id="vpn-panel">
<h2><span class="num">1</span> Checkpoint VPN</h2>
<div class="vpn-status" id="vpn-status-box">
<span class="status-dot" id="vpn-dot"></span>
<span id="vpn-status-text">Disconnected</span>
</div>
<div class="form-row">
<label>Gateway</label>
<input type="text" id="vpn-gateway" value="vpn.telecom.kz">
</div>
<div class="form-row">
<label>Username</label>
<input type="text" id="vpn-user" placeholder="i.kyrykbaev" autocomplete="off">
</div>
<div class="form-row">
<label>Password</label>
<input type="password" id="vpn-pass" autocomplete="off">
</div>
<div class="actions">
<button id="vpn-connect" class="primary">Connect</button>
<button id="vpn-disconnect" disabled>Disconnect</button>
</div>
<label class="checkbox">
<input type="checkbox" id="vpn-skip">
VPN уже включён вручную (пропустить BPMN vpnConnect)
</label>
</section>
<!-- Query Panel -->
<section class="panel" id="query-panel">
<h2><span class="num">2</span> 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>Таблица</label>
<select id="table-select" disabled>
<option value="">— введите учётные данные выше —</option>
</select>
<small class="hint" id="table-hint">Список загружается через <code>SHOW TABLES IN {database}</code></small>
</div>
<div class="form-row">
<label>Промпт для LLM (необязательно)</label>
<textarea id="prompt-input" rows="5" placeholder="Оставьте пустым — будет использован дефолтный аналитический промпт"></textarea>
</div>
<div class="form-row">
<label>LLM провайдер</label>
<div class="radio-group">
<label class="radio"><input type="radio" name="llm" value="ollama" checked> <span>Ollama (local)</span> <small>llama3.1:8b</small></label>
<label class="radio"><input type="radio" name="llm" value="anthropic"> <span>Anthropic API</span> <small>claude-sonnet-4-6</small></label>
</div>
</div>
<button id="run-btn" class="primary big" disabled>▶ Run analysis</button>
</section>
</div>
<div class="col">
<!-- Process Log -->
<section class="panel" id="log-panel">
<h2><span class="num">3</span> BPMN Process Log</h2>
<div class="log-toolbar">
<span class="pi-id" id="pi-id">processInstance: —</span>
<button id="clear-log" class="link">очистить</button>
</div>
<div id="log" class="log"></div>
</section>
<!-- Result -->
<section class="panel" id="result-panel">
<h2><span class="num">4</span> Результат</h2>
<div id="result" class="result placeholder">
Запустите анализ, чтобы увидеть результат от LLM.
</div>
</section>
</div>
</div>
</main>
<footer>
<small>kyrykbaev_i · MVP для хакатона · GUI = HTML/CSS/JS · VPN/Zorro/LLM мокированы · Impala — реальный</small>
</footer>
<script>
// Mock-данные, имитирующие то, что вернул бы реальный воркер:
// - список таблиц из `SHOW TABLES`
// - результат SELECT (rows + columns)
// - аналитический ответ от LLM (Markdown)
//
// В реальной системе всё это приходит через Zorro serviceTask → POST /service-tasks/{id}/complete.
const MOCK_TABLES = [
{ name: 'sales.orders', rows: 1247, description: 'Заказы по регионам' },
{ name: 'sales.customers', rows: 8932, description: 'Клиентская база' },
{ name: 'marketing.campaigns', rows: 156, description: 'Маркетинговые кампании' },
{ name: 'support.tickets', rows: 4521, description: 'Обращения в техподдержку' },
{ name: 'billing.invoices', rows: 12044, description: 'Выставленные счета' },
{ name: 'network.incidents', rows: 287, description: 'Сетевые инциденты' },
];
const MOCK_QUERY_RESULTS = {
'sales.orders': {
columns: ['region', 'orders', 'revenue_kzt', 'avg_check_kzt'],
rows: [
['Almaty', 4521, 187_300_000, 41_430],
['Astana', 3210, 142_500_000, 44_392],
['Shymkent', 1980, 67_400_000, 34_040],
['Karaganda', 1102, 38_900_000, 35_300],
['Aktobe', 856, 24_100_000, 28_155],
['Pavlodar', 643, 19_800_000, 30_793],
],
},
'sales.customers': {
columns: ['segment', 'customers', 'churn_rate_pct', 'ltv_kzt'],
rows: [
['B2B Enterprise', 124, 2.1, 3_450_000],
['B2B SMB', 1843, 8.7, 420_000],
['B2C Premium', 2104, 4.3, 180_000],
['B2C Standard', 4861, 12.4, 65_000],
],
},
'marketing.campaigns': {
columns: ['campaign', 'impressions', 'ctr_pct', 'conversions', 'cpa_kzt'],
rows: [
['New Year Promo', 1_240_000, 3.2, 892, 1_240],
['Summer Internet', 820_000, 2.7, 421, 1_980],
['Family Plan', 540_000, 4.1, 612, 880],
['IPTV Bundle', 310_000, 1.9, 118, 2_630],
],
},
'support.tickets': {
columns: ['category', 'opened', 'resolved', 'avg_resolution_h'],
rows: [
['Internet', 2104, 1987, 4.2],
['IPTV', 842, 801, 6.8],
['Billing', 721, 712, 2.1],
['Mobile', 512, 490, 3.5],
['Hardware', 342, 301, 18.4],
],
},
'billing.invoices': {
columns: ['month', 'issued', 'paid', 'overdue', 'total_kzt'],
rows: [
['2026-01', 4012, 3812, 200, 480_200_000],
['2026-02', 4087, 3901, 186, 491_400_000],
['2026-03', 4133, 3877, 256, 502_100_000],
['2026-04', 4201, 4044, 157, 514_800_000],
],
},
'network.incidents': {
columns: ['severity', 'count', 'mttr_minutes', 'affected_users'],
rows: [
['P1 Critical', 8, 42, 12_400],
['P2 High', 34, 87, 3_210],
['P3 Medium', 102, 184, 640],
['P4 Low', 143, 320, 54],
],
},
};
const DEFAULT_PROMPT = `Ты — старший аналитик данных в Казахтелекоме.
Проанализируй приложенную таблицу и:
1. Дай 3-5 ключевых выводов в виде маркированного списка.
2. Подсвети аномалии и точки роста.
3. Сформулируй 1-2 рекомендации для бизнеса.
4. В конце выведи исходную таблицу в Markdown.`;
// Шаблоны "анализа" — то, что вернул бы реальный LLM.
// Зависят от выбранной таблицы и провайдера (для разнообразия).
const MOCK_ANALYSES = {
'sales.orders': (provider) => `## Аналитика по таблице \`sales.orders\`
**Ключевые наблюдения:**
- **Almaty** генерирует ~38% общей выручки (187.3 млн ₸ из ~480 млн ₸) — главный драйвер.
- **Astana** второй по объёму, но **средний чек выше** (44 392 ₸ против 41 430 ₸ в Алматы) — премиум-сегмент сильнее.
- **Shymkent**: при 3-м месте по числу заказов средний чек **на 18% ниже** среднего — низкомаржинальный регион.
- Регионы **Aktobe** и **Pavlodar** показывают наименьший средний чек (28-31 тыс. ₸), что коррелирует с региональной покупательной способностью.
**Рекомендации:**
- Запустить пилот upsell-программы в Шымкенте для подтягивания среднего чека.
- В Астане усилить B2B-предложения — премиум-аудитория готова платить больше.
**Исходные данные** (источник: \`sales.orders\`, ${provider === 'ollama' ? 'llama3.1:8b' : 'claude-sonnet-4-6'}):
| region | orders | revenue_kzt | avg_check_kzt |
|---|---|---|---|
| Almaty | 4521 | 187 300 000 | 41 430 |
| Astana | 3210 | 142 500 000 | 44 392 |
| Shymkent | 1980 | 67 400 000 | 34 040 |
| Karaganda | 1102 | 38 900 000 | 35 300 |
| Aktobe | 856 | 24 100 000 | 28 155 |
| Pavlodar | 643 | 19 800 000 | 30 793 |`,
'sales.customers': (provider) => `## Аналитика по таблице \`sales.customers\`
**Выводы:**
- Сегмент **B2B Enterprise** даёт самый высокий LTV (3.45 млн ₸) при минимальном churn (2.1%) — стратегически важный сегмент.
- **B2C Standard** — крупнейший по числу клиентов (4 861), но **churn 12.4%** — критическая зона.
- LTV B2C Standard в **53 раза ниже**, чем B2B Enterprise — оправдывает фокус сейлзов на крупных контрактах.
**Аномалии:**
- B2B SMB с churn 8.7% выпадает из ожидаемой картины «B2B = лояльность» — стоит расследовать причины оттока.
**Рекомендации:**
- Retention-кампания для B2C Standard в первый месяц жизни клиента.
- Account-management программа для B2B SMB.
| segment | customers | churn_rate_pct | ltv_kzt |
|---|---|---|---|
| B2B Enterprise | 124 | 2.1 | 3 450 000 |
| B2B SMB | 1843 | 8.7 | 420 000 |
| B2C Premium | 2104 | 4.3 | 180 000 |
| B2C Standard | 4861 | 12.4 | 65 000 |`,
'marketing.campaigns': (provider) => `## Аналитика по таблице \`marketing.campaigns\`
**Выводы:**
- **Family Plan** — лидер по эффективности: CTR 4.1% и самая низкая CPA (880 ₸).
- **IPTV Bundle** — наихудший ROAS: CPA 2 630 ₸ при низком CTR 1.9%. Кандидат на остановку или пересмотр креативов.
- **New Year Promo** дал максимум конверсий (892) благодаря объёму показов, но эффективность средняя.
**Рекомендации:**
- Перераспределить бюджет с IPTV Bundle на Family Plan.
- A/B-тест креативов IPTV Bundle перед следующим запуском.
| campaign | impressions | ctr_pct | conversions | cpa_kzt |
|---|---|---|---|---|
| New Year Promo | 1 240 000 | 3.2 | 892 | 1 240 |
| Summer Internet | 820 000 | 2.7 | 421 | 1 980 |
| Family Plan | 540 000 | 4.1 | 612 | 880 |
| IPTV Bundle | 310 000 | 1.9 | 118 | 2 630 |`,
'support.tickets': (provider) => `## Аналитика по таблице \`support.tickets\`
**Выводы:**
- Категория **Hardware** имеет аномально высокое время решения (18.4 ч против 4.2 ч в Internet) — узкое горлышко.
- **Billing** — самая эффективная категория: 99% resolution rate, 2.1 ч на тикет.
- **Internet** генерирует ~46% всех тикетов — стоит инвестировать в self-service / FAQ.
**Рекомендации:**
- Аудит SLA по Hardware-тикетам и причин затягивания.
- Knowledge base для топ-3 проблем Internet-категории.
| category | opened | resolved | avg_resolution_h |
|---|---|---|---|
| Internet | 2104 | 1987 | 4.2 |
| IPTV | 842 | 801 | 6.8 |
| Billing | 721 | 712 | 2.1 |
| Mobile | 512 | 490 | 3.5 |
| Hardware | 342 | 301 | 18.4 |`,
'billing.invoices': (provider) => `## Аналитика по таблице \`billing.invoices\`
**Выводы:**
- Стабильный рост выручки: с **480.2 → 514.8 млн ₸** за 4 месяца (+7.2%).
- Доля просрочек колеблется 3.7-6.2% — в марте всплеск (256 invoices), требует разбора.
- Conversion paid/issued выше 95% во всех месяцах — биллинг здоров.
**Рекомендации:**
- Расследовать причины мартовского всплеска просрочек (массовая корпоративная задержка? технический баг?).
- Удержать темп +1.5% MoM по выручке через апсейл.
| month | issued | paid | overdue | total_kzt |
|---|---|---|---|---|
| 2026-01 | 4012 | 3812 | 200 | 480 200 000 |
| 2026-02 | 4087 | 3901 | 186 | 491 400 000 |
| 2026-03 | 4133 | 3877 | 256 | 502 100 000 |
| 2026-04 | 4201 | 4044 | 157 | 514 800 000 |`,
'network.incidents': (provider) => `## Аналитика по таблице \`network.incidents\`
**Выводы:**
- **P1 Critical**: всего 8 случаев, но затронули 12 400 пользователей — высокий blast radius.
- **MTTR растёт по убыванию severity** (42 → 320 мин), что нормально, но **P4 320 мин** избыточно для low-severity.
- P3 Medium — самая частая категория по unique-инцидентам (102), но средний MTTR 3+ часа.
**Рекомендации:**
- Автоматизация runbook'ов для P3/P4 — снизит MTTR.
- Post-mortem по всем P1 инцидентам с фокусом на сокращение affected users.
| severity | count | mttr_minutes | affected_users |
|---|---|---|---|
| P1 Critical | 8 | 42 | 12 400 |
| P2 High | 34 | 87 | 3 210 |
| P3 Medium | 102 | 184 | 640 |
| P4 Low | 143 | 320 | 54 |`,
};
function buildAnalysis(table, provider, userPrompt) {
const baseAnalysis = MOCK_ANALYSES[table]
? MOCK_ANALYSES[table](provider)
: `## Аналитика по таблице \`${table}\`\n\n*Mock-данные для этой таблицы не настроены.*`;
if (userPrompt && userPrompt.trim().length > 0) {
// Имитируем, что LLM учёл кастомный промпт
return `> **Использован кастомный промпт:** _"${userPrompt.trim().slice(0, 120)}${userPrompt.length > 120 ? '...' : ''}"_\n\n${baseAnalysis}`;
}
return baseAnalysis;
}
</script>
<script>
// GUI логика: Zorro BPMN мокирован, Impala — реальный API (/api/tables, /api/query).
// Структура эндпоинтов по TRD.md §6 и ZORRO.md §API Reference.
const state = {
vpnUp: false,
running: false,
impalaConnected: false,
};
// ─── Real Impala API ───────────────────────────────────────────────────────────
// Базовый путь для 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(120).then(() => {
logRes(`200 OK { id: "${piId}" }`);
return { id: piId };
});
},
serviceTaskStep(piId, code, durationMs, outputs = {}) {
logEvt(`⏵ ServiceTask: ${code}`);
return delay(durationMs).then(() => {
const taskId = 'st-' + crypto.randomUUID().slice(0, 6);
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;
});
});
},
finishProcess(piId) {
logInfo(`processInstance ${piId} → completedAt set`);
logOk(`✓ Process completed`);
},
};
// ─── 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; }
setVpnStatus('connecting', `Connecting to ${gateway}...`);
disableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass', 'vpn-skip']);
try {
const pi = await mockZorro.startProcess('vpnConnect', [
{ name: 'gateway', value: gateway, type: 'STRING' },
{ name: 'username', value: user, type: 'STRING' },
{ name: 'password', value: '***', type: 'STRING' },
]);
state.vpnPiId = pi.id;
setPiId(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;
} catch (e) {
setVpnStatus('failed', `Failed: ${e.message}`);
enableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass', 'vpn-skip']);
logErr(`✗ VPN: ${e.message}`);
}
}
async function disconnectVpn() {
$('#vpn-disconnect').disabled = true;
setVpnStatus('connecting', 'Disconnecting...');
const pi = await mockZorro.startProcess('vpnDisconnect', []);
setPiId(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']);
}
function onVpnSkipChange() {
const skip = $('#vpn-skip').checked;
if (skip) {
state.vpnUp = true;
setVpnStatus('up', 'Skipped (VPN включён вручную)');
disableForm(['vpn-connect', 'vpn-gateway', 'vpn-user', 'vpn-pass']);
$('#vpn-disconnect').disabled = true;
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']);
}
}
// ─── 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 = '⏳ Выполняется...';
$('#result').classList.remove('placeholder');
$('#result').innerHTML = '<em style="color:var(--text-dim)">Запуск BPMN dailyAnalytics...</em>';
$('#query-panel').classList.add('running');
try {
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 ──
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: '…',
});
}
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);
$('#result').innerHTML = renderMarkdown(resultMarkdown);
} catch (e) {
$('#result').innerHTML = `<p style="color:var(--error)">❌ ${escapeHtml(e.message)}</p>`;
logErr(`✗ ${e.message}`);
} finally {
state.running = false;
$('#run-btn').disabled = false;
$('#run-btn').textContent = '▶ Run analysis';
$('#query-panel').classList.remove('running');
}
}
// Генерирует шаблонный анализ из реальных данных 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) {
$('#vpn-dot').className = 'status-dot ' + (stateName === 'idle' ? '' : stateName);
$('#vpn-status-text').textContent = text;
}
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 ───────────────────────────────────────────────────────────────────
function ts() {
const d = new Date();
return d.toTimeString().slice(0, 8) + '.' + String(d.getMilliseconds()).padStart(3, '0');
}
function appendLog(cls, text, payload) {
const log = $('#log');
const line = document.createElement('span');
line.className = 'log-line ' + cls;
let body = text;
if (payload !== undefined) body += ' ' + JSON.stringify(payload);
line.innerHTML = `<span class="ts">${ts()}</span> ${escapeHtml(body)}`;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
const logReq = (...a) => appendLog('req', '→ ' + a[0], a[1]);
const logRes = (...a) => appendLog('res', '← ' + a[0], a[1]);
const logEvt = (...a) => appendLog('evt', a[0]);
const logOk = (...a) => appendLog('ok', a[0]);
const logErr = (...a) => appendLog('err', a[0]);
const logInfo = (...a) => appendLog('info', ' ' + a[0]);
function clearLog() { $('#log').innerHTML = ''; setPiId('—'); }
// ─── Markdown renderer ─────────────────────────────────────────────────────────
function renderMarkdown(md) {
const lines = md.split('\n');
let html = '', inTable = false, tableRows = [], inList = false;
const flushTable = () => {
if (tableRows.length < 2) { tableRows = []; inTable = false; return; }
const header = tableRows[0], body = tableRows.slice(2);
html += '<table><thead><tr>';
header.forEach(c => html += `<th>${inline(c)}</th>`);
html += '</tr></thead><tbody>';
body.forEach(row => {
html += '<tr>';
row.forEach(c => html += `<td>${inline(c)}</td>`);
html += '</tr>';
});
html += '</tbody></table>';
tableRows = []; inTable = false;
};
const flushList = () => { if (inList) { html += '</ul>'; inList = false; } };
for (const line of lines) {
if (line.trimStart().startsWith('|') && line.trimEnd().endsWith('|')) {
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 (inTable) flushTable();
flushList();
return html;
}
function inline(s) {
return escapeHtml(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>');
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function rowsToMarkdown(qr) {
const head = '| ' + qr.columns.join(' | ') + ' |';
const sep = '| ' + qr.columns.map(() => '---').join(' | ') + ' |';
const body = qr.rows.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 ──────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
$('#prompt-input').placeholder = DEFAULT_PROMPT;
$('#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 ready. Impala API: /api/tables, /api/query. Zorro/LLM — мок.');
logInfo('Хосты: bdas-worker-08 / bdas-utility-01 · LDAP+SSL · port 21050');
});
</script>
</body>
</html>