viberadar 0.3.157 → 0.3.159
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +232 -0
- package/dist/server/index.js.map +1 -1
- package/dist/ui/dashboard.html +492 -1
- package/package.json +1 -1
package/dist/ui/dashboard.html
CHANGED
|
@@ -1438,6 +1438,55 @@
|
|
|
1438
1438
|
.svc-gen-all-btn:hover { opacity:0.85; }
|
|
1439
1439
|
.svc-gen-all-btn:disabled { opacity:0.5; cursor:default; }
|
|
1440
1440
|
|
|
1441
|
+
/* ─── Load Test UI ─────────────────────────────────────────────────────────── */
|
|
1442
|
+
.load-screen { padding: 20px; max-width: 900px; }
|
|
1443
|
+
.load-section { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
|
1444
|
+
.load-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 12px; font-weight: 600; }
|
|
1445
|
+
.load-config-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; align-items: flex-end; }
|
|
1446
|
+
.load-config-field { display: flex; flex-direction: column; gap: 4px; }
|
|
1447
|
+
.load-config-field label { font-size: 11px; color: var(--muted); }
|
|
1448
|
+
.load-config-field input, .load-config-field select {
|
|
1449
|
+
background: var(--bg); border: 1px solid var(--border); border-radius: 5px;
|
|
1450
|
+
color: var(--text); font-size: 13px; padding: 5px 9px; outline: none;
|
|
1451
|
+
}
|
|
1452
|
+
.load-config-field input:focus, .load-config-field select:focus { border-color: var(--blue); }
|
|
1453
|
+
.load-script-editor {
|
|
1454
|
+
width: 100%; min-height: 180px; background: var(--bg); border: 1px solid var(--border);
|
|
1455
|
+
border-radius: 5px; color: var(--text); font-family: 'Cascadia Code', Consolas, monospace;
|
|
1456
|
+
font-size: 12px; padding: 10px; resize: vertical; outline: none; line-height: 1.5;
|
|
1457
|
+
}
|
|
1458
|
+
.load-script-editor:focus { border-color: var(--blue); }
|
|
1459
|
+
.load-btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
|
1460
|
+
.load-btn {
|
|
1461
|
+
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
|
1462
|
+
font-size: 12px; cursor: pointer; background: var(--bg); color: var(--muted);
|
|
1463
|
+
transition: all 0.15s;
|
|
1464
|
+
}
|
|
1465
|
+
.load-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); border-color: var(--dim); }
|
|
1466
|
+
.load-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
1467
|
+
.load-btn-run { background: #1a3a1a; border-color: var(--green); color: var(--green); }
|
|
1468
|
+
.load-btn-run:hover:not(:disabled) { background: #2a4a2a; }
|
|
1469
|
+
.load-btn-stop { background: #3a1a1a; border-color: var(--red); color: var(--red); }
|
|
1470
|
+
.load-btn-stop:hover:not(:disabled) { background: #4a2a2a; }
|
|
1471
|
+
.load-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
1472
|
+
.load-chart-box { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; }
|
|
1473
|
+
.load-chart-label { font-size: 11px; color: var(--muted); margin-bottom: 6px; }
|
|
1474
|
+
.load-chart-box canvas { display: block; width: 100%; height: 100px; }
|
|
1475
|
+
.load-summary-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
|
|
1476
|
+
.load-kpi { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; text-align: center; }
|
|
1477
|
+
.load-kpi-val { font-size: 20px; font-weight: 700; }
|
|
1478
|
+
.load-kpi-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; }
|
|
1479
|
+
.load-log { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; height: 200px; overflow-y: auto; padding: 8px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; }
|
|
1480
|
+
.load-log-line { padding: 1px 0; color: var(--muted); white-space: pre-wrap; word-break: break-all; }
|
|
1481
|
+
.load-status-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
|
1482
|
+
.load-status-running { background: rgba(227,179,65,0.12); color: var(--yellow); border: 1px solid rgba(227,179,65,0.3); }
|
|
1483
|
+
.load-status-done { background: rgba(63,185,80,0.1); color: var(--green); border: 1px solid rgba(63,185,80,0.25); }
|
|
1484
|
+
.load-status-error { background: rgba(248,81,73,0.1); color: var(--red); border: 1px solid rgba(248,81,73,0.25); }
|
|
1485
|
+
.load-status-stopped { background: rgba(125,133,144,0.1); color: var(--muted); border: 1px solid var(--border); }
|
|
1486
|
+
.load-no-k6 { padding: 24px; text-align: center; color: var(--muted); }
|
|
1487
|
+
.load-no-k6 h3 { color: var(--text); margin-bottom: 8px; }
|
|
1488
|
+
.load-no-k6 code { background: var(--bg); border: 1px solid var(--border); padding: 3px 8px; border-radius: 4px; font-size: 13px; color: var(--blue); }
|
|
1489
|
+
|
|
1441
1490
|
</style>
|
|
1442
1491
|
</head>
|
|
1443
1492
|
<body>
|
|
@@ -1567,6 +1616,13 @@ let testNavProblem = null; // null|'no-feature'|'no-source'|'duplicate'|'sta
|
|
|
1567
1616
|
let e2ePlan = null; // current E2E plan object
|
|
1568
1617
|
let e2ePlanLoading = false;
|
|
1569
1618
|
let obsActiveTab = 'overview'; // active observability tab
|
|
1619
|
+
// ── Load test state ──────────────────────────────────────────────────────────
|
|
1620
|
+
let loadState = null; // server LoadState snapshot
|
|
1621
|
+
let loadBuckets = []; // live chart data
|
|
1622
|
+
let loadLogLines = []; // log buffer for display
|
|
1623
|
+
let loadK6Available = null; // null = unchecked, true/false
|
|
1624
|
+
let loadK6Version = '';
|
|
1625
|
+
let loadScriptDraft = ''; // editable k6 script
|
|
1570
1626
|
|
|
1571
1627
|
function toggleObsHint(id) {
|
|
1572
1628
|
document.getElementById(id).classList.toggle('open');
|
|
@@ -1587,12 +1643,14 @@ const modeStore = {
|
|
|
1587
1643
|
docs: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null },
|
|
1588
1644
|
scenarios: { view: 'list', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null },
|
|
1589
1645
|
services: { view: 'graph', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null, svcTab: 'graph' },
|
|
1646
|
+
load: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null },
|
|
1590
1647
|
};
|
|
1591
1648
|
|
|
1592
1649
|
function getModeFromPath(pathname = window.location.pathname) {
|
|
1593
1650
|
if (pathname.startsWith('/radar/observability')) return 'observability';
|
|
1594
1651
|
if (pathname.startsWith('/radar/docs')) return 'docs';
|
|
1595
1652
|
if (pathname.startsWith('/radar/services')) return 'services';
|
|
1653
|
+
if (pathname.startsWith('/radar/load')) return 'load';
|
|
1596
1654
|
return 'qa';
|
|
1597
1655
|
}
|
|
1598
1656
|
|
|
@@ -1600,6 +1658,7 @@ function routePathForMode(mode) {
|
|
|
1600
1658
|
if (mode === 'observability') return '/radar/observability';
|
|
1601
1659
|
if (mode === 'docs') return '/radar/docs';
|
|
1602
1660
|
if (mode === 'services') return '/radar/services';
|
|
1661
|
+
if (mode === 'load') return '/radar/load';
|
|
1603
1662
|
return '/radar/qa';
|
|
1604
1663
|
}
|
|
1605
1664
|
|
|
@@ -1643,13 +1702,14 @@ function switchMode(nextMode) {
|
|
|
1643
1702
|
saveModeState(contextMode);
|
|
1644
1703
|
contextMode = nextMode;
|
|
1645
1704
|
restoreModeState(contextMode);
|
|
1646
|
-
if (contextMode === 'observability' || contextMode === 'docs' || contextMode === 'services') {
|
|
1705
|
+
if (contextMode === 'observability' || contextMode === 'docs' || contextMode === 'services' || contextMode === 'load') {
|
|
1647
1706
|
view = 'features';
|
|
1648
1707
|
drillFeatureKey = null;
|
|
1649
1708
|
drillTestType = null;
|
|
1650
1709
|
activePanelKey = null;
|
|
1651
1710
|
clearFeatureHash();
|
|
1652
1711
|
}
|
|
1712
|
+
if (contextMode === 'load' && loadK6Available === null) { checkK6(); }
|
|
1653
1713
|
setModeRoute(contextMode);
|
|
1654
1714
|
document.getElementById('searchInput').value = searchQuery;
|
|
1655
1715
|
document.getElementById('panel').classList.remove('open');
|
|
@@ -2945,6 +3005,7 @@ function renderModeSwitch() {
|
|
|
2945
3005
|
{ key: 'docs', label: 'Документация', hint: 'Актуальность, генерация, обновление' },
|
|
2946
3006
|
{ key: 'scenarios', label: 'Сценарии', hint: 'Пользовательские сценарии, user journeys' },
|
|
2947
3007
|
{ key: 'services', label: 'Карта сервисов', hint: 'Зависимости, пайплайны, мониторинг' },
|
|
3008
|
+
{ key: 'load', label: 'Нагрузка', hint: 'k6: метрики, сценарии, AI-анализ' },
|
|
2948
3009
|
];
|
|
2949
3010
|
root.innerHTML = modes.map(m => `
|
|
2950
3011
|
<button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
|
|
@@ -2997,6 +3058,32 @@ function renderSidebar() {
|
|
|
2997
3058
|
return;
|
|
2998
3059
|
}
|
|
2999
3060
|
|
|
3061
|
+
if (contextMode === 'load') {
|
|
3062
|
+
tabs.style.display = 'none';
|
|
3063
|
+
const ls = loadState;
|
|
3064
|
+
const statusColor = !ls ? 'var(--dim)' : ls.status === 'running' ? 'var(--yellow)' : ls.status === 'done' ? 'var(--green)' : 'var(--muted)';
|
|
3065
|
+
const statusLabel = !ls ? '—' : ls.status === 'running' ? 'Запущено' : ls.status === 'done' ? 'Завершено' : ls.status === 'stopped' ? 'Остановлено' : ls.status === 'error' ? 'Ошибка' : '—';
|
|
3066
|
+
extra.innerHTML = `
|
|
3067
|
+
<div class="sidebar-label">Нагрузочное тестирование</div>
|
|
3068
|
+
<div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45;margin-bottom:12px">
|
|
3069
|
+
k6-сценарии для API и фич. Живые графики, AI-анализ результатов.
|
|
3070
|
+
</div>
|
|
3071
|
+
<div style="padding:0 6px;font-size:12px">
|
|
3072
|
+
<div style="color:var(--dim);margin-bottom:4px">Статус</div>
|
|
3073
|
+
<div style="color:${statusColor};font-weight:600">${statusLabel}</div>
|
|
3074
|
+
</div>
|
|
3075
|
+
${ls && ls.status !== 'idle' ? `
|
|
3076
|
+
<div style="padding:8px 6px 0;font-size:12px;color:var(--muted)">
|
|
3077
|
+
<div>Запросов: <span style="color:var(--text)">${ls.totalRequests || 0}</span></div>
|
|
3078
|
+
<div>Ошибок: <span style="color:${(ls.totalErrors||0)>0?'var(--red)':'var(--text)'}">${ls.totalErrors || 0}</span></div>
|
|
3079
|
+
${ls.summary ? `<div>RPS: <span style="color:var(--text)">${(ls.summary.rps||0).toFixed(1)}</span></div>
|
|
3080
|
+
<div>avg: <span style="color:var(--text)">${Math.round(ls.summary.avgDuration||0)}ms</span></div>
|
|
3081
|
+
<div>p90: <span style="color:var(--text)">${Math.round(ls.summary.p90Duration||0)}ms</span></div>` : ''}
|
|
3082
|
+
</div>` : ''}
|
|
3083
|
+
${loadK6Available === false ? `<div style="padding:8px 6px;font-size:11px;color:var(--red)">⚠ k6 не найден.<br>Установите: <code>choco install k6</code></div>` : ''}`;
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3000
3087
|
if (contextMode === 'services') {
|
|
3001
3088
|
tabs.style.display = 'none';
|
|
3002
3089
|
const svcTab = modeStore.services.svcTab || 'graph';
|
|
@@ -3077,6 +3164,10 @@ function renderContent() {
|
|
|
3077
3164
|
renderServiceMap(c);
|
|
3078
3165
|
return;
|
|
3079
3166
|
}
|
|
3167
|
+
if (contextMode === 'load') {
|
|
3168
|
+
renderLoad(c);
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3080
3171
|
|
|
3081
3172
|
if (view === 'features') {
|
|
3082
3173
|
if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
|
|
@@ -6608,6 +6699,368 @@ async function refreshData() {
|
|
|
6608
6699
|
}
|
|
6609
6700
|
}
|
|
6610
6701
|
|
|
6702
|
+
// ─── Load Testing ─────────────────────────────────────────────────────────────
|
|
6703
|
+
|
|
6704
|
+
async function checkK6() {
|
|
6705
|
+
try {
|
|
6706
|
+
const r = await fetch('/api/load/check');
|
|
6707
|
+
const d = await r.json();
|
|
6708
|
+
loadK6Available = d.available;
|
|
6709
|
+
loadK6Version = d.version || '';
|
|
6710
|
+
} catch { loadK6Available = false; }
|
|
6711
|
+
if (contextMode === 'load') { renderSidebar(); renderContent(); }
|
|
6712
|
+
}
|
|
6713
|
+
|
|
6714
|
+
function buildDefaultScript(cfg) {
|
|
6715
|
+
const baseUrl = cfg.baseUrl || 'http://localhost:3000';
|
|
6716
|
+
const endpoints = (cfg.endpoints || ['/']).filter(Boolean);
|
|
6717
|
+
const endpointLines = endpoints.map(ep =>
|
|
6718
|
+
` const r = http.get(\`\${BASE_URL}${ep}\`);\n check(r, { 'status 2xx': (res) => res.status >= 200 && res.status < 300 });`
|
|
6719
|
+
).join('\n');
|
|
6720
|
+
return `import http from 'k6/http';
|
|
6721
|
+
import { check, sleep } from 'k6';
|
|
6722
|
+
|
|
6723
|
+
const BASE_URL = '${baseUrl}';
|
|
6724
|
+
|
|
6725
|
+
export const options = {
|
|
6726
|
+
vus: ${cfg.vus || 10},
|
|
6727
|
+
duration: '${cfg.duration || '30s'}',
|
|
6728
|
+
thresholds: {
|
|
6729
|
+
http_req_duration: ['p(95)<2000'],
|
|
6730
|
+
http_req_failed: ['rate<0.05'],
|
|
6731
|
+
},
|
|
6732
|
+
};
|
|
6733
|
+
|
|
6734
|
+
export default function () {
|
|
6735
|
+
${endpointLines}
|
|
6736
|
+
sleep(1);
|
|
6737
|
+
}
|
|
6738
|
+
`;
|
|
6739
|
+
}
|
|
6740
|
+
|
|
6741
|
+
function generateScriptFromFeature(featureKey) {
|
|
6742
|
+
const feat = D && D.features && D.features.find(f => f.key === featureKey);
|
|
6743
|
+
if (!feat) return null;
|
|
6744
|
+
// Try to extract API endpoints from modules in this feature
|
|
6745
|
+
const mods = (D.modules || []).filter(m => m.featureKeys && m.featureKeys.includes(featureKey));
|
|
6746
|
+
const apiMods = mods.filter(m => m.type === 'service' || (m.name && /api|service|controller|handler/i.test(m.name)));
|
|
6747
|
+
const endpoints = apiMods.slice(0, 5).map(m => '/' + m.name.replace(/\.(ts|js)$/, '').replace(/\\/g, '/'));
|
|
6748
|
+
return buildDefaultScript({
|
|
6749
|
+
baseUrl: 'http://localhost:3000',
|
|
6750
|
+
vus: 10,
|
|
6751
|
+
duration: '30s',
|
|
6752
|
+
endpoints: endpoints.length ? endpoints : ['/'],
|
|
6753
|
+
});
|
|
6754
|
+
}
|
|
6755
|
+
|
|
6756
|
+
function drawLoadCharts() {
|
|
6757
|
+
if (!loadBuckets || loadBuckets.length === 0) return;
|
|
6758
|
+
drawLoadChart('loadChartRps', loadBuckets, b => b.count / 2, 'var(--blue)', 'RPS');
|
|
6759
|
+
drawLoadChart('loadChartLatency', loadBuckets, b => b.count > 0 ? b.durSum / b.count : 0, 'var(--green)', 'Latency avg (ms)');
|
|
6760
|
+
drawLoadChart('loadChartErrors', loadBuckets, b => b.count > 0 ? (b.errors / b.count) * 100 : 0, 'var(--red)', 'Error %');
|
|
6761
|
+
drawLoadChart('loadChartVus', loadBuckets, b => b.vus, 'var(--yellow)', 'VUs');
|
|
6762
|
+
}
|
|
6763
|
+
|
|
6764
|
+
function drawLoadChart(id, buckets, valFn, color, label) {
|
|
6765
|
+
const canvas = document.getElementById(id);
|
|
6766
|
+
if (!canvas) return;
|
|
6767
|
+
const dpr = window.devicePixelRatio || 1;
|
|
6768
|
+
const W = canvas.offsetWidth || 380;
|
|
6769
|
+
const H = 100;
|
|
6770
|
+
canvas.width = W * dpr;
|
|
6771
|
+
canvas.height = H * dpr;
|
|
6772
|
+
const ctx = canvas.getContext('2d');
|
|
6773
|
+
ctx.scale(dpr, dpr);
|
|
6774
|
+
ctx.clearRect(0, 0, W, H);
|
|
6775
|
+
|
|
6776
|
+
const vals = buckets.map(valFn);
|
|
6777
|
+
const maxVal = Math.max(...vals, 1);
|
|
6778
|
+
const pad = { l: 36, r: 8, t: 8, b: 20 };
|
|
6779
|
+
const cW = W - pad.l - pad.r;
|
|
6780
|
+
const cH = H - pad.t - pad.b;
|
|
6781
|
+
const step = cW / Math.max(vals.length - 1, 1);
|
|
6782
|
+
|
|
6783
|
+
// Grid line
|
|
6784
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
|
6785
|
+
ctx.lineWidth = 1;
|
|
6786
|
+
[0.25, 0.5, 0.75, 1].forEach(f => {
|
|
6787
|
+
const y = pad.t + cH * (1 - f);
|
|
6788
|
+
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
|
|
6789
|
+
});
|
|
6790
|
+
|
|
6791
|
+
// Y axis labels
|
|
6792
|
+
ctx.fillStyle = 'rgba(125,133,144,0.8)';
|
|
6793
|
+
ctx.font = `9px system-ui`;
|
|
6794
|
+
ctx.textAlign = 'right';
|
|
6795
|
+
[0, 0.5, 1].forEach(f => {
|
|
6796
|
+
const y = pad.t + cH * (1 - f);
|
|
6797
|
+
const v = maxVal * f;
|
|
6798
|
+
const txt = v >= 1000 ? (v/1000).toFixed(1)+'k' : v >= 10 ? Math.round(v).toString() : v.toFixed(1);
|
|
6799
|
+
ctx.fillText(txt, pad.l - 3, y + 3);
|
|
6800
|
+
});
|
|
6801
|
+
|
|
6802
|
+
if (vals.length === 0) return;
|
|
6803
|
+
|
|
6804
|
+
// Area fill
|
|
6805
|
+
const grad = ctx.createLinearGradient(0, pad.t, 0, pad.t + cH);
|
|
6806
|
+
grad.addColorStop(0, color.replace(')', ', 0.3)').replace('var(', 'var('));
|
|
6807
|
+
// Simple approach: just use semi-transparent fill
|
|
6808
|
+
ctx.fillStyle = 'rgba(88,166,255,0.08)';
|
|
6809
|
+
if (color.includes('green')) ctx.fillStyle = 'rgba(63,185,80,0.08)';
|
|
6810
|
+
if (color.includes('red')) ctx.fillStyle = 'rgba(248,81,73,0.08)';
|
|
6811
|
+
if (color.includes('yellow')) ctx.fillStyle = 'rgba(227,179,65,0.08)';
|
|
6812
|
+
|
|
6813
|
+
ctx.beginPath();
|
|
6814
|
+
ctx.moveTo(pad.l, pad.t + cH);
|
|
6815
|
+
vals.forEach((v, i) => {
|
|
6816
|
+
const x = pad.l + i * step;
|
|
6817
|
+
const y = pad.t + cH * (1 - v / maxVal);
|
|
6818
|
+
if (i === 0) ctx.lineTo(x, y); else ctx.lineTo(x, y);
|
|
6819
|
+
});
|
|
6820
|
+
ctx.lineTo(pad.l + (vals.length - 1) * step, pad.t + cH);
|
|
6821
|
+
ctx.closePath();
|
|
6822
|
+
ctx.fill();
|
|
6823
|
+
|
|
6824
|
+
// Line
|
|
6825
|
+
ctx.beginPath();
|
|
6826
|
+
ctx.lineWidth = 1.5;
|
|
6827
|
+
// Resolve CSS variable colors to actual colors
|
|
6828
|
+
const colorMap = { 'var(--blue)': '#58a6ff', 'var(--green)': '#3fb950', 'var(--red)': '#f85149', 'var(--yellow)': '#e3b341' };
|
|
6829
|
+
ctx.strokeStyle = colorMap[color] || color;
|
|
6830
|
+
vals.forEach((v, i) => {
|
|
6831
|
+
const x = pad.l + i * step;
|
|
6832
|
+
const y = pad.t + cH * (1 - v / maxVal);
|
|
6833
|
+
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
6834
|
+
});
|
|
6835
|
+
ctx.stroke();
|
|
6836
|
+
|
|
6837
|
+
// Label
|
|
6838
|
+
ctx.fillStyle = 'rgba(125,133,144,0.9)';
|
|
6839
|
+
ctx.font = '10px system-ui';
|
|
6840
|
+
ctx.textAlign = 'left';
|
|
6841
|
+
ctx.fillText(label, pad.l + 2, H - 4);
|
|
6842
|
+
}
|
|
6843
|
+
|
|
6844
|
+
function renderLoad(c) {
|
|
6845
|
+
const isRunning = loadState && loadState.status === 'running';
|
|
6846
|
+
const isDone = loadState && (loadState.status === 'done' || loadState.status === 'stopped');
|
|
6847
|
+
const hasErr = loadState && loadState.status === 'error';
|
|
6848
|
+
|
|
6849
|
+
// k6 install check message
|
|
6850
|
+
if (loadK6Available === false) {
|
|
6851
|
+
c.innerHTML = `<div class="load-screen"><div class="load-no-k6">
|
|
6852
|
+
<h3>⚠ k6 не найден</h3>
|
|
6853
|
+
<p style="margin-bottom:12px">Установите k6 для запуска нагрузочного тестирования:</p>
|
|
6854
|
+
<div style="display:flex;flex-direction:column;gap:8px;align-items:center">
|
|
6855
|
+
<div><code>choco install k6</code> <span style="color:var(--muted)">— Windows (Chocolatey)</span></div>
|
|
6856
|
+
<div><code>winget install k6</code> <span style="color:var(--muted)">— Windows (winget)</span></div>
|
|
6857
|
+
<div><code>brew install k6</code> <span style="color:var(--muted)">— macOS (Homebrew)</span></div>
|
|
6858
|
+
</div>
|
|
6859
|
+
<button class="load-btn" style="margin-top:16px" onclick="checkK6()">🔄 Проверить снова</button>
|
|
6860
|
+
</div></div>`;
|
|
6861
|
+
return;
|
|
6862
|
+
}
|
|
6863
|
+
|
|
6864
|
+
if (loadK6Available === null) {
|
|
6865
|
+
c.innerHTML = `<div class="load-screen" style="padding:40px;text-align:center;color:var(--muted)">Проверяю k6…</div>`;
|
|
6866
|
+
return;
|
|
6867
|
+
}
|
|
6868
|
+
|
|
6869
|
+
const features = (D && D.features) || [];
|
|
6870
|
+
const featureOptions = features.map(f =>
|
|
6871
|
+
`<option value="${f.key}">${f.label || f.key}</option>`
|
|
6872
|
+
).join('');
|
|
6873
|
+
|
|
6874
|
+
// Build script draft if empty
|
|
6875
|
+
if (!loadScriptDraft) {
|
|
6876
|
+
loadScriptDraft = buildDefaultScript({ baseUrl: 'http://localhost:3000', vus: 10, duration: '30s', endpoints: ['/'] });
|
|
6877
|
+
}
|
|
6878
|
+
|
|
6879
|
+
const statusBadge = !loadState || loadState.status === 'idle' ? '' :
|
|
6880
|
+
loadState.status === 'running' ? '<span class="load-status-badge load-status-running">● Запущено</span>' :
|
|
6881
|
+
loadState.status === 'done' ? '<span class="load-status-badge load-status-done">✓ Завершено</span>' :
|
|
6882
|
+
loadState.status === 'stopped' ? '<span class="load-status-badge load-status-stopped">■ Остановлено</span>' :
|
|
6883
|
+
'<span class="load-status-badge load-status-error">✗ Ошибка</span>';
|
|
6884
|
+
|
|
6885
|
+
const summary = (loadState && loadState.summary) || {};
|
|
6886
|
+
const hasSummary = isDone && summary && Object.keys(summary).length > 0;
|
|
6887
|
+
|
|
6888
|
+
c.innerHTML = `<div class="load-screen">
|
|
6889
|
+
|
|
6890
|
+
<div class="load-section">
|
|
6891
|
+
<div class="load-section-title">Конфигурация ${loadK6Version ? `<span style="color:var(--dim);font-weight:400">${escapeHtml(loadK6Version)}</span>` : ''} ${statusBadge}</div>
|
|
6892
|
+
<div class="load-config-row">
|
|
6893
|
+
<div class="load-config-field" style="flex:1;min-width:200px">
|
|
6894
|
+
<label>Base URL</label>
|
|
6895
|
+
<input id="loadBaseUrl" type="text" value="http://localhost:3000" placeholder="http://localhost:3000" />
|
|
6896
|
+
</div>
|
|
6897
|
+
<div class="load-config-field">
|
|
6898
|
+
<label>VUs</label>
|
|
6899
|
+
<input id="loadVus" type="number" value="10" min="1" max="500" style="width:70px" />
|
|
6900
|
+
</div>
|
|
6901
|
+
<div class="load-config-field">
|
|
6902
|
+
<label>Duration</label>
|
|
6903
|
+
<input id="loadDuration" type="text" value="30s" style="width:70px" placeholder="30s" />
|
|
6904
|
+
</div>
|
|
6905
|
+
${featureOptions ? `<div class="load-config-field">
|
|
6906
|
+
<label>Фича (шаблон)</label>
|
|
6907
|
+
<select id="loadFeature" style="width:150px">
|
|
6908
|
+
<option value="">— выбрать —</option>
|
|
6909
|
+
${featureOptions}
|
|
6910
|
+
</select>
|
|
6911
|
+
</div>` : ''}
|
|
6912
|
+
</div>
|
|
6913
|
+
<div class="load-btns">
|
|
6914
|
+
<button class="load-btn" onclick="loadGenerateScript()">⚙ Сгенерировать скрипт</button>
|
|
6915
|
+
<button class="load-btn load-btn-run" id="loadRunBtn" onclick="runLoadTest()" ${isRunning ? 'disabled' : ''}>▶ Запустить</button>
|
|
6916
|
+
<button class="load-btn load-btn-stop" id="loadStopBtn" onclick="stopLoadTest()" ${!isRunning ? 'disabled' : ''}>■ Стоп</button>
|
|
6917
|
+
</div>
|
|
6918
|
+
</div>
|
|
6919
|
+
|
|
6920
|
+
<div class="load-section">
|
|
6921
|
+
<div class="load-section-title">k6 скрипт <span style="font-weight:400;color:var(--dim)">(редактируемый)</span></div>
|
|
6922
|
+
<textarea class="load-script-editor" id="loadScriptEditor" spellcheck="false">${escapeHtml(loadScriptDraft)}</textarea>
|
|
6923
|
+
</div>
|
|
6924
|
+
|
|
6925
|
+
${(isRunning || isDone || hasErr) ? `<div class="load-section">
|
|
6926
|
+
<div class="load-section-title">Живые метрики</div>
|
|
6927
|
+
<div class="load-charts">
|
|
6928
|
+
<div class="load-chart-box"><div class="load-chart-label">Запросов/сек (RPS)</div><canvas id="loadChartRps"></canvas></div>
|
|
6929
|
+
<div class="load-chart-box"><div class="load-chart-label">Среднее время ответа (мс)</div><canvas id="loadChartLatency"></canvas></div>
|
|
6930
|
+
<div class="load-chart-box"><div class="load-chart-label">Процент ошибок (%)</div><canvas id="loadChartErrors"></canvas></div>
|
|
6931
|
+
<div class="load-chart-box"><div class="load-chart-label">Активных VUs</div><canvas id="loadChartVus"></canvas></div>
|
|
6932
|
+
</div>
|
|
6933
|
+
</div>` : ''}
|
|
6934
|
+
|
|
6935
|
+
${hasSummary ? `<div class="load-section">
|
|
6936
|
+
<div class="load-section-title">Итоги</div>
|
|
6937
|
+
<div class="load-summary-grid">
|
|
6938
|
+
${summary.rps != null ? `<div class="load-kpi"><div class="load-kpi-val" style="color:var(--blue)">${(summary.rps||0).toFixed(1)}</div><div class="load-kpi-lbl">RPS</div></div>` : ''}
|
|
6939
|
+
${summary.avgDuration != null ? `<div class="load-kpi"><div class="load-kpi-val">${Math.round(summary.avgDuration||0)}</div><div class="load-kpi-lbl">avg ms</div></div>` : ''}
|
|
6940
|
+
${summary.p90Duration != null ? `<div class="load-kpi"><div class="load-kpi-val">${Math.round(summary.p90Duration||0)}</div><div class="load-kpi-lbl">p90 ms</div></div>` : ''}
|
|
6941
|
+
${summary.p95Duration != null ? `<div class="load-kpi"><div class="load-kpi-val">${Math.round(summary.p95Duration||0)}</div><div class="load-kpi-lbl">p95 ms</div></div>` : ''}
|
|
6942
|
+
${summary.totalRequests != null ? `<div class="load-kpi"><div class="load-kpi-val">${summary.totalRequests||0}</div><div class="load-kpi-lbl">Запросов</div></div>` : ''}
|
|
6943
|
+
${summary.errorPct != null ? `<div class="load-kpi"><div class="load-kpi-val" style="color:${(summary.errorPct||0)>5?'var(--red)':'var(--green)'}">${(summary.errorPct||0).toFixed(2)}%</div><div class="load-kpi-lbl">Ошибок</div></div>` : ''}
|
|
6944
|
+
</div>
|
|
6945
|
+
<div class="load-btns" style="margin-top:12px">
|
|
6946
|
+
<button class="load-btn" style="background:#1a2a3a;border-color:var(--blue);color:var(--blue)" onclick="loadAiAnalysis()">🤖 AI-анализ результатов</button>
|
|
6947
|
+
</div>
|
|
6948
|
+
</div>` : ''}
|
|
6949
|
+
|
|
6950
|
+
<div class="load-section">
|
|
6951
|
+
<div class="load-section-title">Лог k6</div>
|
|
6952
|
+
<div class="load-log" id="loadLogContent">
|
|
6953
|
+
${loadLogLines.map(l => `<div class="load-log-line">${escapeHtml(l)}</div>`).join('')}
|
|
6954
|
+
</div>
|
|
6955
|
+
</div>
|
|
6956
|
+
|
|
6957
|
+
</div>`;
|
|
6958
|
+
|
|
6959
|
+
// Scroll log to bottom
|
|
6960
|
+
const logEl = document.getElementById('loadLogContent');
|
|
6961
|
+
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
6962
|
+
|
|
6963
|
+
// Sync textarea with draft
|
|
6964
|
+
const ta = document.getElementById('loadScriptEditor');
|
|
6965
|
+
if (ta) {
|
|
6966
|
+
ta.addEventListener('input', () => { loadScriptDraft = ta.value; });
|
|
6967
|
+
}
|
|
6968
|
+
|
|
6969
|
+
// Feature selector auto-generates script
|
|
6970
|
+
const featSel = document.getElementById('loadFeature');
|
|
6971
|
+
if (featSel) {
|
|
6972
|
+
featSel.addEventListener('change', () => {
|
|
6973
|
+
const fk = featSel.value;
|
|
6974
|
+
if (!fk) return;
|
|
6975
|
+
const script = generateScriptFromFeature(fk);
|
|
6976
|
+
if (script) {
|
|
6977
|
+
loadScriptDraft = script;
|
|
6978
|
+
const ta2 = document.getElementById('loadScriptEditor');
|
|
6979
|
+
if (ta2) ta2.value = script;
|
|
6980
|
+
}
|
|
6981
|
+
});
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
// Draw charts if we have data
|
|
6985
|
+
if (loadBuckets && loadBuckets.length > 0) {
|
|
6986
|
+
requestAnimationFrame(drawLoadCharts);
|
|
6987
|
+
}
|
|
6988
|
+
}
|
|
6989
|
+
|
|
6990
|
+
function loadGenerateScript() {
|
|
6991
|
+
const baseUrl = document.getElementById('loadBaseUrl')?.value || 'http://localhost:3000';
|
|
6992
|
+
const vus = parseInt(document.getElementById('loadVus')?.value || '10');
|
|
6993
|
+
const duration = document.getElementById('loadDuration')?.value || '30s';
|
|
6994
|
+
const featKey = document.getElementById('loadFeature')?.value || '';
|
|
6995
|
+
let script;
|
|
6996
|
+
if (featKey) {
|
|
6997
|
+
script = generateScriptFromFeature(featKey) || buildDefaultScript({ baseUrl, vus, duration, endpoints: ['/'] });
|
|
6998
|
+
} else {
|
|
6999
|
+
script = buildDefaultScript({ baseUrl, vus, duration, endpoints: ['/api/health', '/api/data'] });
|
|
7000
|
+
}
|
|
7001
|
+
loadScriptDraft = script;
|
|
7002
|
+
const ta = document.getElementById('loadScriptEditor');
|
|
7003
|
+
if (ta) ta.value = script;
|
|
7004
|
+
}
|
|
7005
|
+
|
|
7006
|
+
async function runLoadTest() {
|
|
7007
|
+
const ta = document.getElementById('loadScriptEditor');
|
|
7008
|
+
const script = ta ? ta.value : loadScriptDraft;
|
|
7009
|
+
if (!script.trim()) { alert('Скрипт пустой — сначала сгенерируйте или напишите k6-скрипт'); return; }
|
|
7010
|
+
|
|
7011
|
+
loadLogLines = [];
|
|
7012
|
+
loadBuckets = [];
|
|
7013
|
+
|
|
7014
|
+
try {
|
|
7015
|
+
const r = await fetch('/api/load/run', {
|
|
7016
|
+
method: 'POST',
|
|
7017
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7018
|
+
body: JSON.stringify({ script }),
|
|
7019
|
+
});
|
|
7020
|
+
const d = await r.json();
|
|
7021
|
+
if (!r.ok) { alert('Ошибка запуска: ' + (d.error || r.status)); return; }
|
|
7022
|
+
} catch (e) {
|
|
7023
|
+
alert('Ошибка: ' + e.message);
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
|
|
7027
|
+
async function stopLoadTest() {
|
|
7028
|
+
try { await fetch('/api/load/stop', { method: 'POST' }); } catch {}
|
|
7029
|
+
}
|
|
7030
|
+
|
|
7031
|
+
async function loadAiAnalysis() {
|
|
7032
|
+
if (!loadState || !loadState.summary) return;
|
|
7033
|
+
const summary = loadState.summary;
|
|
7034
|
+
const logs = loadState.logs || [];
|
|
7035
|
+
const prompt = `Проанализируй результаты нагрузочного тестирования k6:
|
|
7036
|
+
|
|
7037
|
+
RPS: ${(summary.rps||0).toFixed(2)}
|
|
7038
|
+
avg latency: ${Math.round(summary.avgDuration||0)}ms
|
|
7039
|
+
p90 latency: ${Math.round(summary.p90Duration||0)}ms
|
|
7040
|
+
p95 latency: ${Math.round(summary.p95Duration||0)}ms
|
|
7041
|
+
Total requests: ${summary.totalRequests||0}
|
|
7042
|
+
Error rate: ${(summary.errorPct||0).toFixed(2)}%
|
|
7043
|
+
|
|
7044
|
+
Лог k6:
|
|
7045
|
+
${logs.slice(-50).join('\n')}
|
|
7046
|
+
|
|
7047
|
+
Оцени: производительность, узкие места, рекомендации по оптимизации.`;
|
|
7048
|
+
|
|
7049
|
+
// Open agent terminal and send task
|
|
7050
|
+
document.getElementById('agentPanel').classList.add('open');
|
|
7051
|
+
document.getElementById('termBtn').classList.add('term-active');
|
|
7052
|
+
|
|
7053
|
+
try {
|
|
7054
|
+
const r = await fetch('/api/run-agent', {
|
|
7055
|
+
method: 'POST',
|
|
7056
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7057
|
+
body: JSON.stringify({ task: 'custom-prompt', prompt }),
|
|
7058
|
+
});
|
|
7059
|
+
const d = await r.json();
|
|
7060
|
+
if (!r.ok) alert('Ошибка запуска агента: ' + (d.error || r.status));
|
|
7061
|
+
} catch (e) { alert('Ошибка: ' + e.message); }
|
|
7062
|
+
}
|
|
7063
|
+
|
|
6611
7064
|
function connectSSE() {
|
|
6612
7065
|
const es = new EventSource('/api/events');
|
|
6613
7066
|
|
|
@@ -6905,6 +7358,44 @@ function connectSSE() {
|
|
|
6905
7358
|
}
|
|
6906
7359
|
});
|
|
6907
7360
|
|
|
7361
|
+
// ── Load test SSE events ────────────────────────────────────────────────────
|
|
7362
|
+
es.addEventListener('load-started', (e) => {
|
|
7363
|
+
const { config } = JSON.parse(e.data);
|
|
7364
|
+
loadState = { status: 'running', startTime: Date.now(), buckets: [], totalRequests: 0, totalErrors: 0, logs: [], script: config?.script || '', config, summary: null };
|
|
7365
|
+
loadBuckets = [];
|
|
7366
|
+
loadLogLines = [];
|
|
7367
|
+
if (contextMode === 'load') { renderSidebar(); renderContent(); }
|
|
7368
|
+
});
|
|
7369
|
+
|
|
7370
|
+
es.addEventListener('load-log', (e) => {
|
|
7371
|
+
const { line } = JSON.parse(e.data);
|
|
7372
|
+
loadLogLines.push(line);
|
|
7373
|
+
if (loadLogLines.length > 500) loadLogLines.shift();
|
|
7374
|
+
if (contextMode === 'load') {
|
|
7375
|
+
const logEl = document.getElementById('loadLogContent');
|
|
7376
|
+
if (logEl) {
|
|
7377
|
+
const div = document.createElement('div');
|
|
7378
|
+
div.className = 'load-log-line';
|
|
7379
|
+
div.textContent = line;
|
|
7380
|
+
logEl.appendChild(div);
|
|
7381
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
});
|
|
7385
|
+
|
|
7386
|
+
es.addEventListener('load-progress', (e) => {
|
|
7387
|
+
const { buckets, total, errors } = JSON.parse(e.data);
|
|
7388
|
+
loadBuckets = buckets || [];
|
|
7389
|
+
if (loadState) { loadState.totalRequests = total || 0; loadState.totalErrors = errors || 0; }
|
|
7390
|
+
if (contextMode === 'load') { drawLoadCharts(); renderSidebar(); }
|
|
7391
|
+
});
|
|
7392
|
+
|
|
7393
|
+
es.addEventListener('load-done', (e) => {
|
|
7394
|
+
const { status, summary } = JSON.parse(e.data);
|
|
7395
|
+
if (loadState) { loadState.status = status; loadState.summary = summary || null; loadState.endTime = Date.now(); }
|
|
7396
|
+
if (contextMode === 'load') { renderSidebar(); renderContent(); }
|
|
7397
|
+
});
|
|
7398
|
+
|
|
6908
7399
|
es.onerror = () => {
|
|
6909
7400
|
setLiveDot('var(--dim)', 'Нет соединения — переподключаюсь…');
|
|
6910
7401
|
es.close();
|