viberadar 0.3.157 → 0.3.158

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.
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberadar",
3
- "version": "0.3.157",
3
+ "version": "0.3.158",
4
4
  "description": "Live module map with test coverage for vibecoding projects",
5
5
  "main": "./dist/cli.js",
6
6
  "bin": {