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.
1150 lines
43 KiB
HTML
1150 lines
43 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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>
|