viberadar 0.3.208 → 0.3.209

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.
@@ -1498,13 +1498,25 @@
1498
1498
  padding: 14px 16px; cursor: pointer; transition: border-color 0.15s, background 0.15s;
1499
1499
  }
1500
1500
  .load-library-card:hover { border-color: var(--blue); background: #1a2a3a22; }
1501
- .load-library-card-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
1502
- .load-library-card-meta { font-size: 11px; color: var(--muted); margin-bottom: 10px; }
1503
- .load-library-card-actions { display: flex; gap: 6px; }
1504
- .load-library-empty {
1505
- display: flex; flex-direction: column; align-items: center; justify-content: center;
1506
- padding: 60px 20px; text-align: center; color: var(--dim);
1507
- }
1501
+ .load-library-card-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
1502
+ .load-library-card-meta { font-size: 11px; color: var(--muted); margin-bottom: 10px; }
1503
+ .load-library-card-actions { display: flex; gap: 6px; }
1504
+ .load-run-list { display: flex; flex-direction: column; gap: 6px; }
1505
+ .load-run-item {
1506
+ display: grid; grid-template-columns: 1fr auto auto auto; gap: 10px; align-items: center;
1507
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
1508
+ padding: 8px 10px; cursor: pointer; font-size: 12px;
1509
+ }
1510
+ .load-run-item:hover { border-color: var(--blue); }
1511
+ .load-run-main { min-width: 0; }
1512
+ .load-run-name { color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1513
+ .load-run-meta { color: var(--muted); font-size: 11px; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1514
+ .load-run-kpi { color: var(--muted); font-size: 11px; white-space: nowrap; text-align: right; }
1515
+ .load-config-hint { font-size: 11px; color: var(--muted); margin-top: 8px; line-height: 1.45; }
1516
+ .load-library-empty {
1517
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1518
+ padding: 60px 20px; text-align: center; color: var(--dim);
1519
+ }
1508
1520
  /* Editor topbar */
1509
1521
  .load-editor-topbar {
1510
1522
  display: flex; align-items: center; gap: 10px;
@@ -1765,10 +1777,14 @@ let loadK6Available = null; // null = unchecked, true/false
1765
1777
  let loadK6Version = '';
1766
1778
  let loadScriptDraft = ''; // editable k6 script
1767
1779
  let loadAiPromptDraft = ''; // AI prompt textarea draft
1768
- let loadAiGenerating = false; // waiting for agent to produce script
1769
- let loadSavedScripts = []; // [{ name, date, script }]
1770
- let loadScriptNameDraft = ''; // save name input draft
1771
- let loadView = 'library'; // 'library' | 'editor'
1780
+ let loadAiGenerating = false; // waiting for agent to produce script
1781
+ let loadSavedScripts = []; // [{ name, date, script }]
1782
+ let loadRuns = []; // saved k6 run history
1783
+ let loadScriptNameDraft = ''; // save name input draft
1784
+ let loadBaseUrlDraft = 'http://localhost:5000';
1785
+ let loadVusDraft = 10;
1786
+ let loadDurationDraft = '30s';
1787
+ let loadView = 'library'; // 'library' | 'editor'
1772
1788
 
1773
1789
  function toggleObsHint(id) {
1774
1790
  document.getElementById(id).classList.toggle('open');
@@ -1859,12 +1875,14 @@ function switchMode(nextMode) {
1859
1875
  clearFeatureHash();
1860
1876
  }
1861
1877
  if (contextMode === 'probe') { loadProbeData(); }
1862
- if (contextMode === 'load' && loadK6Available === null) { checkK6(); }
1863
- if (contextMode === 'load') {
1864
- loadRefreshScripts();
1865
- // reset to library view when switching to load tab (unless test is running)
1866
- const isRunning = loadState && loadState.status === 'running';
1867
- if (!isRunning) loadView = 'library';
1878
+ if (contextMode === 'load' && loadK6Available === null) { checkK6(); }
1879
+ if (contextMode === 'load') {
1880
+ loadRefreshScripts();
1881
+ loadRefreshRuns();
1882
+ loadFetchResults();
1883
+ // reset to library view when switching to load tab (unless test is running)
1884
+ const isRunning = loadState && loadState.status === 'running';
1885
+ if (!isRunning) loadView = 'library';
1868
1886
  }
1869
1887
  setModeRoute(contextMode);
1870
1888
  document.getElementById('searchInput').value = searchQuery;
@@ -3145,8 +3163,9 @@ async function init() {
3145
3163
  setModeRoute(contextMode, true);
3146
3164
  document.getElementById('searchInput').value = searchQuery;
3147
3165
 
3148
- document.getElementById('loading').style.display = 'none';
3149
- if (contextMode === 'probe') { await loadProbeData(); }
3166
+ document.getElementById('loading').style.display = 'none';
3167
+ if (contextMode === 'probe') { await loadProbeData(); }
3168
+ if (contextMode === 'load') { await Promise.all([loadRefreshScripts(), loadRefreshRuns(), loadFetchResults(), checkK6()]); }
3150
3169
  renderStats();
3151
3170
  renderSidebar();
3152
3171
  renderContent();
@@ -3313,21 +3332,22 @@ function renderSidebar() {
3313
3332
  const statusLabel = !ls ? '—' : ls.status === 'running' ? 'Запущено' : ls.status === 'done' ? 'Завершено' : ls.status === 'stopped' ? 'Остановлено' : ls.status === 'error' ? 'Ошибка' : '—';
3314
3333
  extra.innerHTML = `
3315
3334
  <div class="sidebar-label">Нагрузочное тестирование</div>
3316
- <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45;margin-bottom:12px">
3317
- k6-сценарии для API и фич. Живые графики, AI-анализ результатов.
3318
- </div>
3319
- <div style="padding:0 6px;font-size:12px">
3320
- <div style="color:var(--dim);margin-bottom:4px">Статус</div>
3321
- <div style="color:${statusColor};font-weight:600">${statusLabel}</div>
3322
- </div>
3323
- ${ls && ls.status !== 'idle' ? `
3324
- <div style="padding:8px 6px 0;font-size:12px;color:var(--muted)">
3325
- <div>Запросов: <span style="color:var(--text)">${ls.totalRequests || 0}</span></div>
3326
- <div>Ошибок: <span style="color:${(ls.totalErrors||0)>0?'var(--red)':'var(--text)'}">${ls.totalErrors || 0}</span></div>
3327
- ${ls.summary ? `<div>RPS: <span style="color:var(--text)">${(ls.summary.rps||0).toFixed(1)}</span></div>
3328
- <div>avg: <span style="color:var(--text)">${Math.round(ls.summary.avgDuration||0)}ms</span></div>
3329
- <div>p90: <span style="color:var(--text)">${Math.round(ls.summary.p90Duration||0)}ms</span></div>` : ''}
3330
- </div>` : ''}
3335
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45;margin-bottom:12px">
3336
+ k6-сценарии для API и фич. Живые графики, история запусков, AI-анализ результатов.
3337
+ </div>
3338
+ <div style="padding:0 6px;font-size:12px">
3339
+ <div style="color:var(--dim);margin-bottom:4px">Статус</div>
3340
+ <div style="color:${statusColor};font-weight:600">${statusLabel}</div>
3341
+ <div style="color:var(--dim);margin-top:8px">История: <span style="color:var(--text)">${loadRuns.length}</span></div>
3342
+ </div>
3343
+ ${ls && ls.status !== 'idle' ? `
3344
+ <div style="padding:8px 6px 0;font-size:12px;color:var(--muted)">
3345
+ <div>Запросов: <span style="color:var(--text)">${ls.totalRequests || 0}</span></div>
3346
+ <div>Ошибок: <span style="color:${(ls.totalErrors||0)>0?'var(--red)':'var(--text)'}">${ls.totalErrors || 0}</span></div>
3347
+ ${ls.summary ? `<div>RPS: <span style="color:var(--text)">${(ls.summary.rps||0).toFixed(1)}</span></div>
3348
+ <div>avg: <span style="color:var(--text)">${Math.round(ls.summary.avgDuration||0)}ms</span></div>
3349
+ <div>p95: <span style="color:var(--text)">${Math.round(ls.summary.p95Duration||0)}ms</span></div>` : ''}
3350
+ </div>` : ''}
3331
3351
  ${loadK6Available === false ? `<div style="padding:8px 6px;font-size:11px;color:var(--red)">⚠ k6 не найден.<br>Установите: <code>choco install k6</code></div>` : ''}`;
3332
3352
  return;
3333
3353
  }
@@ -7591,19 +7611,19 @@ async function checkK6() {
7591
7611
  }
7592
7612
  }
7593
7613
 
7594
- function buildDefaultScript(cfg) {
7595
- const baseUrl = cfg.baseUrl || 'http://localhost:5000';
7596
- const endpoints = (cfg.endpoints || ['/']).filter(Boolean);
7597
- const endpointLines = endpoints.map(ep =>
7598
- ` const r = http.get(\`\${BASE_URL}${ep}\`);\n check(r, { 'status 2xx': (res) => res.status >= 200 && res.status < 300 });`
7599
- ).join('\n');
7600
- return `import http from 'k6/http';
7601
- import { check, sleep } from 'k6';
7602
-
7603
- const BASE_URL = '${baseUrl}';
7604
-
7605
- export const options = {
7606
- vus: ${cfg.vus || 10},
7614
+ function buildDefaultScript(cfg) {
7615
+ const baseUrl = cfg.baseUrl || 'http://localhost:5000';
7616
+ const endpoints = (cfg.endpoints || ['/']).filter(Boolean);
7617
+ const endpointLines = endpoints.map(ep =>
7618
+ ` const r = http.get(\`\${BASE_URL}${ep}\`);\n check(r, { 'status 2xx': (res) => res.status >= 200 && res.status < 300 });`
7619
+ ).join('\n');
7620
+ return `import http from 'k6/http';
7621
+ import { check, sleep } from 'k6';
7622
+
7623
+ const BASE_URL = __ENV.BASE_URL || '${baseUrl}';
7624
+
7625
+ export const options = {
7626
+ vus: ${cfg.vus || 10},
7607
7627
  duration: '${cfg.duration || '30s'}',
7608
7628
  thresholds: {
7609
7629
  http_req_duration: ['p(95)<2000'],
@@ -7625,13 +7645,13 @@ function generateScriptFromFeature(featureKey) {
7625
7645
  const mods = (D.modules || []).filter(m => m.featureKeys && m.featureKeys.includes(featureKey));
7626
7646
  const apiMods = mods.filter(m => m.type === 'service' || (m.name && /api|service|controller|handler/i.test(m.name)));
7627
7647
  const endpoints = apiMods.slice(0, 5).map(m => '/' + m.name.replace(/\.(ts|js)$/, '').replace(/\\/g, '/'));
7628
- return buildDefaultScript({
7629
- baseUrl: 'http://localhost:5000',
7630
- vus: 10,
7631
- duration: '30s',
7632
- endpoints: endpoints.length ? endpoints : ['/'],
7633
- });
7634
- }
7648
+ return buildDefaultScript({
7649
+ baseUrl: loadBaseUrlDraft || 'http://localhost:5000',
7650
+ vus: loadVusDraft || 10,
7651
+ duration: loadDurationDraft || '30s',
7652
+ endpoints: endpoints.length ? endpoints : ['/'],
7653
+ });
7654
+ }
7635
7655
 
7636
7656
  function drawLoadCharts() {
7637
7657
  if (!loadBuckets || loadBuckets.length === 0) return;
@@ -7641,7 +7661,7 @@ function drawLoadCharts() {
7641
7661
  drawLoadChart('loadChartVus', loadBuckets, b => b.vus, 'var(--yellow)', 'VUs');
7642
7662
  }
7643
7663
 
7644
- function drawLoadChart(id, buckets, valFn, color, label) {
7664
+ function drawLoadChart(id, buckets, valFn, color, label) {
7645
7665
  const canvas = document.getElementById(id);
7646
7666
  if (!canvas) return;
7647
7667
  const dpr = window.devicePixelRatio || 1;
@@ -7718,10 +7738,36 @@ function drawLoadChart(id, buckets, valFn, color, label) {
7718
7738
  ctx.fillStyle = 'rgba(125,133,144,0.9)';
7719
7739
  ctx.font = '10px system-ui';
7720
7740
  ctx.textAlign = 'left';
7721
- ctx.fillText(label, pad.l + 2, H - 4);
7722
- }
7723
-
7724
- function renderLoad(c) {
7741
+ ctx.fillText(label, pad.l + 2, H - 4);
7742
+ }
7743
+
7744
+ function formatLoadRunDate(ts) {
7745
+ if (!ts) return '—';
7746
+ try { return new Date(ts).toLocaleString(); } catch { return '—'; }
7747
+ }
7748
+
7749
+ function loadStatusLabel(status) {
7750
+ return status === 'running' ? 'Запущено'
7751
+ : status === 'done' ? 'Завершено'
7752
+ : status === 'stopped' ? 'Остановлено'
7753
+ : status === 'error' ? 'Ошибка'
7754
+ : '—';
7755
+ }
7756
+
7757
+ function applyLoadConfigToFields(cfg) {
7758
+ if (!cfg) return;
7759
+ loadBaseUrlDraft = cfg.baseUrl || loadBaseUrlDraft;
7760
+ loadVusDraft = cfg.vus || loadVusDraft;
7761
+ loadDurationDraft = cfg.duration || loadDurationDraft;
7762
+ const baseEl = document.getElementById('loadBaseUrl');
7763
+ const vusEl = document.getElementById('loadVus');
7764
+ const durEl = document.getElementById('loadDuration');
7765
+ if (baseEl) baseEl.value = loadBaseUrlDraft;
7766
+ if (vusEl) vusEl.value = loadVusDraft;
7767
+ if (durEl) durEl.value = loadDurationDraft;
7768
+ }
7769
+
7770
+ function renderLoad(c) {
7725
7771
  const isRunning = loadState && loadState.status === 'running';
7726
7772
  const isDone = loadState && (loadState.status === 'done' || loadState.status === 'stopped');
7727
7773
  const hasErr = loadState && loadState.status === 'error';
@@ -7749,17 +7795,31 @@ function renderLoad(c) {
7749
7795
  const view = isRunning ? 'editor' : loadView;
7750
7796
 
7751
7797
  // ─── LIBRARY VIEW ─────────────────────────────────────────────────────────
7752
- if (view === 'library') {
7753
- const cards = loadSavedScripts.map(s => `
7754
- <div class="load-library-card" data-sname="${escapeHtml(s.name)}">
7755
- <div class="load-library-card-name">${escapeHtml(s.name)}</div>
7756
- <div class="load-library-card-meta">${escapeHtml(s.date)}</div>
7757
- </div>`).join('');
7758
-
7759
- c.innerHTML = `<div class="load-screen">
7760
- <div class="load-library-header">
7761
- <div>
7762
- <div style="font-size:16px;font-weight:600;color:var(--fg)">Нагрузочные тесты</div>
7798
+ if (view === 'library') {
7799
+ const cards = loadSavedScripts.map(s => `
7800
+ <div class="load-library-card" data-sname="${escapeHtml(s.name)}">
7801
+ <div class="load-library-card-name">${escapeHtml(s.name)}</div>
7802
+ <div class="load-library-card-meta">${escapeHtml(s.date)} · ${s.vus || 10} VU · ${escapeHtml(s.duration || '30s')}</div>
7803
+ </div>`).join('');
7804
+ const runRows = loadRuns.slice(0, 12).map(r => {
7805
+ const summary = r.summary || {};
7806
+ const cfg = r.config || {};
7807
+ const err = summary.errorPct != null ? `${Number(summary.errorPct || 0).toFixed(2)}%` : '—';
7808
+ return `<div class="load-run-item" data-run-id="${escapeHtml(r.runId)}">
7809
+ <div class="load-run-main">
7810
+ <div class="load-run-name">${escapeHtml(r.scriptName || 'Без названия')}</div>
7811
+ <div class="load-run-meta">${formatLoadRunDate(r.createdAt || r.startTime)} · ${cfg.vus || '—'} VU · ${escapeHtml(cfg.duration || '—')} · ${loadStatusLabel(r.status)}</div>
7812
+ </div>
7813
+ <div class="load-run-kpi">${summary.rps != null ? Number(summary.rps || 0).toFixed(1) : '—'} RPS</div>
7814
+ <div class="load-run-kpi">p95 ${summary.p95Duration != null ? Math.round(summary.p95Duration || 0) + 'ms' : '—'}</div>
7815
+ <div class="load-run-kpi" style="color:${Number(summary.errorPct || 0) > 5 ? 'var(--red)' : 'var(--muted)'}">${err}</div>
7816
+ </div>`;
7817
+ }).join('');
7818
+
7819
+ c.innerHTML = `<div class="load-screen">
7820
+ <div class="load-library-header">
7821
+ <div>
7822
+ <div style="font-size:16px;font-weight:600;color:var(--fg)">Нагрузочные тесты</div>
7763
7823
  <div style="font-size:12px;color:var(--muted);margin-top:2px">${loadK6Version ? escapeHtml(loadK6Version) : ''} · ${loadSavedScripts.length} сохранённых</div>
7764
7824
  </div>
7765
7825
  <button class="load-btn load-btn-run" style="font-size:13px;padding:8px 20px" onclick="loadNewTest()">+ Новый тест</button>
@@ -7772,16 +7832,24 @@ function renderLoad(c) {
7772
7832
  <div style="font-size:12px;color:var(--muted);margin-bottom:16px">Создай первый тест — напиши k6 скрипт или сгенерируй через AI</div>
7773
7833
  <button class="load-btn load-btn-run" onclick="loadNewTest()">+ Создать первый тест</button>
7774
7834
  </div>`
7775
- : `<div class="load-library-grid">${cards}</div>`
7776
- }
7777
- </div>`;
7835
+ : `<div class="load-library-grid">${cards}</div>`
7836
+ }
7837
+
7838
+ <div class="load-section" style="margin:0 20px 20px">
7839
+ <div class="load-section-title">История запусков</div>
7840
+ ${runRows ? `<div class="load-run-list">${runRows}</div>` : '<div style="font-size:12px;color:var(--muted)">Запусков пока нет</div>'}
7841
+ </div>
7842
+ </div>`;
7778
7843
 
7779
7844
  // Attach click handlers via data attribute — avoids quoting issues
7780
- c.querySelectorAll('.load-library-card[data-sname]').forEach(el => {
7781
- el.addEventListener('click', () => loadOpenScript(el.dataset.sname));
7782
- });
7783
- return;
7784
- }
7845
+ c.querySelectorAll('.load-library-card[data-sname]').forEach(el => {
7846
+ el.addEventListener('click', () => loadOpenScript(el.dataset.sname));
7847
+ });
7848
+ c.querySelectorAll('.load-run-item[data-run-id]').forEach(el => {
7849
+ el.addEventListener('click', () => loadOpenRun(el.dataset.runId));
7850
+ });
7851
+ return;
7852
+ }
7785
7853
 
7786
7854
  // ─── EDITOR VIEW ──────────────────────────────────────────────────────────
7787
7855
  const features = (D && D.features) || [];
@@ -7789,9 +7857,9 @@ function renderLoad(c) {
7789
7857
  `<option value="${f.key}">${f.label || f.key}</option>`
7790
7858
  ).join('');
7791
7859
 
7792
- if (!loadScriptDraft) {
7793
- loadScriptDraft = buildDefaultScript({ baseUrl: 'http://localhost:5000', vus: 10, duration: '30s', endpoints: ['/'] });
7794
- }
7860
+ if (!loadScriptDraft) {
7861
+ loadScriptDraft = buildDefaultScript({ baseUrl: loadBaseUrlDraft, vus: loadVusDraft, duration: loadDurationDraft, endpoints: ['/'] });
7862
+ }
7795
7863
 
7796
7864
  const statusBadge = !loadState || loadState.status === 'idle' ? '' :
7797
7865
  loadState.status === 'running' ? '<span class="load-status-badge load-status-running">● Запущено</span>' :
@@ -7813,19 +7881,20 @@ function renderLoad(c) {
7813
7881
  <div class="load-section-title">Конфигурация ${loadK6Version ? `<span style="color:var(--dim);font-weight:400">${escapeHtml(loadK6Version)}</span>` : ''}</div>
7814
7882
  <div class="load-config-row">
7815
7883
  <div class="load-config-field" style="flex:1;min-width:200px">
7816
- <label>Base URL</label>
7817
- <input id="loadBaseUrl" type="text" value="http://localhost:5000" placeholder="http://localhost:5000" />
7818
- </div>
7819
- <div class="load-config-field">
7820
- <label>VUs</label>
7821
- <input id="loadVus" type="number" value="10" min="1" max="500" style="width:70px" />
7822
- </div>
7823
- <div class="load-config-field">
7824
- <label>Duration</label>
7825
- <input id="loadDuration" type="text" value="30s" style="width:70px" placeholder="30s" />
7826
- </div>
7827
- </div>
7828
- <div class="load-config-row" style="margin-top:8px">
7884
+ <label>Base URL</label>
7885
+ <input id="loadBaseUrl" type="text" value="${escapeHtml(loadBaseUrlDraft)}" placeholder="http://localhost:5000" />
7886
+ </div>
7887
+ <div class="load-config-field">
7888
+ <label>VUs</label>
7889
+ <input id="loadVus" type="number" value="${escapeHtml(String(loadVusDraft))}" min="1" max="10000" style="width:80px" />
7890
+ </div>
7891
+ <div class="load-config-field">
7892
+ <label>Duration</label>
7893
+ <input id="loadDuration" type="text" value="${escapeHtml(loadDurationDraft)}" style="width:80px" placeholder="30s" />
7894
+ </div>
7895
+ </div>
7896
+ <div class="load-config-hint">VUs и Duration применяются через CLI k6 и переопределяют значения из <code>export const options</code>. Base URL доступен в скрипте как <code>__ENV.BASE_URL</code>.</div>
7897
+ <div class="load-config-row" style="margin-top:8px">
7829
7898
  <div class="load-config-field" style="flex:1;min-width:300px">
7830
7899
  <label>Bearer Token <span style="color:var(--dim);font-weight:400;font-size:11px">(→ <code style="background:var(--bg2);padding:1px 4px;border-radius:3px">__ENV.TOKEN</code>)</span></label>
7831
7900
  <input id="loadToken" type="password" placeholder="Вставь Bearer-токен (необязательно)" style="font-family:monospace;font-size:12px" oninput="localStorage.setItem('vr_load_token', this.value)" />
@@ -7879,11 +7948,13 @@ function renderLoad(c) {
7879
7948
  <div class="load-summary-grid">
7880
7949
  ${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>` : ''}
7881
7950
  ${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>` : ''}
7882
- ${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>` : ''}
7883
- ${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>` : ''}
7884
- ${summary.totalRequests != null ? `<div class="load-kpi"><div class="load-kpi-val">${summary.totalRequests||0}</div><div class="load-kpi-lbl">Запросов</div></div>` : ''}
7885
- ${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>` : ''}
7886
- </div>
7951
+ ${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>` : ''}
7952
+ ${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>` : ''}
7953
+ ${summary.p99Duration != null ? `<div class="load-kpi"><div class="load-kpi-val">${Math.round(summary.p99Duration||0)}</div><div class="load-kpi-lbl">p99 ms</div></div>` : ''}
7954
+ ${summary.totalRequests != null ? `<div class="load-kpi"><div class="load-kpi-val">${summary.totalRequests||0}</div><div class="load-kpi-lbl">Запросов</div></div>` : ''}
7955
+ ${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>` : ''}
7956
+ ${summary.checksFailed != null ? `<div class="load-kpi"><div class="load-kpi-val" style="color:${(summary.checksFailed||0)>0?'var(--red)':'var(--green)'}">${summary.checksFailed||0}</div><div class="load-kpi-lbl">check fails</div></div>` : ''}
7957
+ </div>
7887
7958
  <div class="load-btns" style="margin-top:12px">
7888
7959
  <button class="load-btn" style="background:#1a2a3a;border-color:var(--blue);color:var(--blue)" onclick="loadAiAnalysis()">🤖 AI-анализ результатов</button>
7889
7960
  </div>
@@ -7906,9 +7977,15 @@ function renderLoad(c) {
7906
7977
  const savedToken = localStorage.getItem('vr_load_token');
7907
7978
  if (savedToken) { const el = document.getElementById('loadToken'); if (el) el.value = savedToken; }
7908
7979
 
7909
- // Sync textarea with draft
7910
- const ta = document.getElementById('loadScriptEditor');
7911
- if (ta) { ta.addEventListener('input', () => { loadScriptDraft = ta.value; }); }
7980
+ // Sync textarea with draft
7981
+ const ta = document.getElementById('loadScriptEditor');
7982
+ if (ta) { ta.addEventListener('input', () => { loadScriptDraft = ta.value; }); }
7983
+ const baseInput = document.getElementById('loadBaseUrl');
7984
+ const vusInput = document.getElementById('loadVus');
7985
+ const durationInput = document.getElementById('loadDuration');
7986
+ if (baseInput) baseInput.addEventListener('input', () => { loadBaseUrlDraft = baseInput.value || 'http://localhost:5000'; });
7987
+ if (vusInput) vusInput.addEventListener('input', () => { loadVusDraft = parseInt(vusInput.value || '10', 10) || 10; });
7988
+ if (durationInput) durationInput.addEventListener('input', () => { loadDurationDraft = durationInput.value || '30s'; });
7912
7989
 
7913
7990
  // Feature selector auto-generates script
7914
7991
  const featSel = document.getElementById('loadFeature');
@@ -7938,14 +8015,17 @@ function loadNewTest() {
7938
8015
  renderContent();
7939
8016
  }
7940
8017
 
7941
- function loadOpenScript(name) {
7942
- const s = loadSavedScripts.find(x => x.name === name);
7943
- if (!s) return;
7944
- loadScriptDraft = s.script;
7945
- loadScriptNameDraft = s.name;
7946
- loadView = 'editor';
7947
- renderContent();
7948
- }
8018
+ function loadOpenScript(name) {
8019
+ const s = loadSavedScripts.find(x => x.name === name);
8020
+ if (!s) return;
8021
+ loadScriptDraft = s.script;
8022
+ loadScriptNameDraft = s.name;
8023
+ if (s.baseUrl) loadBaseUrlDraft = s.baseUrl;
8024
+ if (s.vus) loadVusDraft = s.vus;
8025
+ if (s.duration) loadDurationDraft = s.duration;
8026
+ loadView = 'editor';
8027
+ renderContent();
8028
+ }
7949
8029
 
7950
8030
  function loadGenerateScript() {
7951
8031
  const baseUrl = document.getElementById('loadBaseUrl')?.value || 'http://localhost:5000';
@@ -7963,23 +8043,31 @@ function loadGenerateScript() {
7963
8043
  if (ta) ta.value = script;
7964
8044
  }
7965
8045
 
7966
- async function runLoadTest() {
7967
- const ta = document.getElementById('loadScriptEditor');
7968
- const script = ta ? ta.value : loadScriptDraft;
7969
- if (!script.trim()) { alert('Скрипт пустой — сначала сгенерируйте или напишите k6-скрипт'); return; }
7970
-
7971
- loadLogLines = [];
7972
- loadBuckets = [];
8046
+ async function runLoadTest() {
8047
+ const ta = document.getElementById('loadScriptEditor');
8048
+ const script = ta ? ta.value : loadScriptDraft;
8049
+ if (!script.trim()) { alert('Скрипт пустой — сначала сгенерируйте или напишите k6-скрипт'); return; }
8050
+ const baseUrl = (document.getElementById('loadBaseUrl')?.value || loadBaseUrlDraft || 'http://localhost:5000').trim();
8051
+ const vus = parseInt(document.getElementById('loadVus')?.value || String(loadVusDraft || 10), 10) || 10;
8052
+ const duration = (document.getElementById('loadDuration')?.value || loadDurationDraft || '30s').trim();
8053
+ const scriptName = (document.getElementById('loadScriptName')?.value || loadScriptNameDraft || 'Новый тест').trim();
8054
+ loadBaseUrlDraft = baseUrl;
8055
+ loadVusDraft = vus;
8056
+ loadDurationDraft = duration;
8057
+ loadScriptNameDraft = scriptName;
8058
+
8059
+ loadLogLines = [];
8060
+ loadBuckets = [];
7973
8061
 
7974
8062
  try {
7975
8063
  const tokenVal = (document.getElementById('loadToken')?.value || localStorage.getItem('vr_load_token') || '').trim();
7976
8064
  const envVars = tokenVal ? { TOKEN: tokenVal } : {};
7977
8065
 
7978
- const r = await fetch('/api/load/run', {
7979
- method: 'POST',
7980
- headers: { 'Content-Type': 'application/json' },
7981
- body: JSON.stringify({ script, envVars }),
7982
- });
8066
+ const r = await fetch('/api/load/run', {
8067
+ method: 'POST',
8068
+ headers: { 'Content-Type': 'application/json' },
8069
+ body: JSON.stringify({ script, vus, duration, baseUrl, scriptName, envVars }),
8070
+ });
7983
8071
  const d = await r.json();
7984
8072
  if (!r.ok) { alert('Ошибка запуска: ' + (d.error || r.status)); return; }
7985
8073
  } catch (e) {
@@ -8056,15 +8144,16 @@ ${featureList || '(нет данных)'}
8056
8144
  - посмотри на метод, путь, body, заголовки
8057
8145
  - найди нужные ID, ключи, форматы данных из реального кода или конфигов
8058
8146
 
8059
- **Шаг 2 — составь k6 скрипт.**
8060
- Требования:
8061
- 1. Валидный JavaScript для k6 (import from 'k6/http', 'k6', 'k6/data')
8062
- 2. \`export const options = { vus: ${vus}, duration: '${duration}', thresholds: {...} }\`
8063
- 3. \`export default function() { ... }\` с проверками \`check()\`
8064
- 4. Если нужна авторизация добавь заголовок Authorization (Bearer-токен как переменную __ENV.TOKEN)
8065
- 5. Если нужна загрузка файла используй \`http.file()\` и \`open()\`
8066
- 6. Добавь \`sleep(1)\` между запросами
8067
- 7. Добавь комментарий в начале: что тестируется и какой эндпоинт
8147
+ **Шаг 2 — составь k6 скрипт.**
8148
+ Требования:
8149
+ 1. Валидный JavaScript для k6 (import from 'k6/http', 'k6', 'k6/data')
8150
+ 2. VUs и duration задаёт VibeRadar через CLI, поэтому не хардкодь нагрузку как единственный источник истины; если добавляешь \`export const options\`, оставь там только thresholds/summaryTrendStats или безопасные дефолты
8151
+ 3. \`export default function() { ... }\` с проверками \`check()\`
8152
+ 4. Base URL бери из \`__ENV.BASE_URL || '${baseUrl}'\`
8153
+ 5. Если нужна авторизациядобавь заголовок Authorization (Bearer-токен как переменную __ENV.TOKEN)
8154
+ 6. Если нужна загрузка файла — используй \`http.file()\` и \`open()\`
8155
+ 7. Добавь \`sleep(1)\` между запросами
8156
+ 8. Добавь комментарий в начале: что тестируется и какой эндпоинт
8068
8157
 
8069
8158
  **Шаг 3 — сохрани ТОЛЬКО скрипт в файл.**
8070
8159
  Запиши итоговый JavaScript-код в файл: \`.viberadar/load-script-generated.js\`
@@ -8116,7 +8205,7 @@ async function loadFetchAiScript() {
8116
8205
  return false;
8117
8206
  }
8118
8207
 
8119
- async function loadRefreshScripts() {
8208
+ async function loadRefreshScripts() {
8120
8209
  try {
8121
8210
  const r = await fetch('/api/load/scripts');
8122
8211
  if (!r.ok) return;
@@ -8124,22 +8213,67 @@ async function loadRefreshScripts() {
8124
8213
  // Only re-render if we're on load tab in library view (don't disrupt editor)
8125
8214
  if (contextMode === 'load' && loadView === 'library') renderContent();
8126
8215
  } catch {}
8127
- }
8128
-
8129
- async function loadSaveScript() {
8216
+ }
8217
+
8218
+ async function loadRefreshRuns() {
8219
+ try {
8220
+ const r = await fetch('/api/load/runs');
8221
+ if (!r.ok) return;
8222
+ loadRuns = await r.json();
8223
+ if (contextMode === 'load' && loadView === 'library') renderContent();
8224
+ } catch {}
8225
+ }
8226
+
8227
+ async function loadFetchResults() {
8228
+ try {
8229
+ const r = await fetch('/api/load/results');
8230
+ if (!r.ok) return;
8231
+ const d = await r.json();
8232
+ if (d && d.status && d.status !== 'idle') {
8233
+ loadState = d;
8234
+ loadBuckets = d.buckets || [];
8235
+ loadLogLines = d.logs || [];
8236
+ if (d.config) applyLoadConfigToFields(d.config);
8237
+ }
8238
+ } catch {}
8239
+ }
8240
+
8241
+ async function loadOpenRun(runId) {
8242
+ try {
8243
+ const r = await fetch('/api/load/runs/' + encodeURIComponent(runId));
8244
+ if (!r.ok) return;
8245
+ const d = await r.json();
8246
+ loadState = d;
8247
+ loadBuckets = d.buckets || [];
8248
+ loadLogLines = d.logs || [];
8249
+ loadScriptDraft = d.script || '';
8250
+ loadScriptNameDraft = d.config?.scriptName || d.scriptName || '';
8251
+ if (d.config) applyLoadConfigToFields(d.config);
8252
+ loadView = 'editor';
8253
+ renderContent();
8254
+ } catch {}
8255
+ }
8256
+
8257
+ async function loadSaveScript() {
8130
8258
  const nameEl = document.getElementById('loadScriptName');
8131
8259
  const name = (nameEl ? nameEl.value : loadScriptNameDraft).trim();
8132
8260
  if (!name) { alert('Введи название скрипта'); nameEl && nameEl.focus(); return; }
8133
- const taEl = document.getElementById('loadScriptEditor');
8134
- const script = taEl ? taEl.value : loadScriptDraft;
8135
- if (!script.trim()) { alert('Скрипт пустой'); return; }
8136
- loadScriptNameDraft = name;
8137
- try {
8138
- const r = await fetch('/api/load/scripts', {
8139
- method: 'POST',
8140
- headers: { 'Content-Type': 'application/json' },
8141
- body: JSON.stringify({ name, script }),
8142
- });
8261
+ const taEl = document.getElementById('loadScriptEditor');
8262
+ const script = taEl ? taEl.value : loadScriptDraft;
8263
+ if (!script.trim()) { alert('Скрипт пустой'); return; }
8264
+ const baseUrl = (document.getElementById('loadBaseUrl')?.value || loadBaseUrlDraft || 'http://localhost:5000').trim();
8265
+ const vus = parseInt(document.getElementById('loadVus')?.value || String(loadVusDraft || 10), 10) || 10;
8266
+ const duration = (document.getElementById('loadDuration')?.value || loadDurationDraft || '30s').trim();
8267
+ loadScriptNameDraft = name;
8268
+ loadBaseUrlDraft = baseUrl;
8269
+ loadVusDraft = vus;
8270
+ loadDurationDraft = duration;
8271
+ try {
8272
+ const r = await fetch('/api/load/scripts', {
8273
+ method: 'POST',
8274
+ headers: { 'Content-Type': 'application/json' },
8275
+ body: JSON.stringify({ name, script, baseUrl, vus, duration }),
8276
+ });
8143
8277
  if (!r.ok) { const d = await r.json().catch(() => ({})); alert('Ошибка: ' + (d.error || r.status)); return; }
8144
8278
  await loadRefreshScripts();
8145
8279
  // flash save button
@@ -8148,16 +8282,20 @@ async function loadSaveScript() {
8148
8282
  } catch (e) { alert('Ошибка: ' + e.message); }
8149
8283
  }
8150
8284
 
8151
- async function loadLoadScript(name) {
8285
+ async function loadLoadScript(name) {
8152
8286
  const s = loadSavedScripts.find(x => x.name === name);
8153
8287
  if (!s) return;
8154
- loadScriptDraft = s.script;
8155
- loadScriptNameDraft = s.name;
8156
- const ta = document.getElementById('loadScriptEditor');
8157
- if (ta) { ta.value = s.script; ta.style.borderColor = 'var(--blue)'; setTimeout(() => { ta.style.borderColor = ''; }, 1500); }
8158
- const nameEl = document.getElementById('loadScriptName');
8159
- if (nameEl) nameEl.value = s.name;
8160
- }
8288
+ loadScriptDraft = s.script;
8289
+ loadScriptNameDraft = s.name;
8290
+ if (s.baseUrl) loadBaseUrlDraft = s.baseUrl;
8291
+ if (s.vus) loadVusDraft = s.vus;
8292
+ if (s.duration) loadDurationDraft = s.duration;
8293
+ const ta = document.getElementById('loadScriptEditor');
8294
+ if (ta) { ta.value = s.script; ta.style.borderColor = 'var(--blue)'; setTimeout(() => { ta.style.borderColor = ''; }, 1500); }
8295
+ const nameEl = document.getElementById('loadScriptName');
8296
+ if (nameEl) nameEl.value = s.name;
8297
+ applyLoadConfigToFields(s);
8298
+ }
8161
8299
 
8162
8300
  async function loadDeleteScript(name) {
8163
8301
  if (!confirm(`Удалить скрипт «${name}»?`)) return;
@@ -8478,13 +8616,14 @@ function connectSSE() {
8478
8616
  });
8479
8617
 
8480
8618
  // ── Load test SSE events ────────────────────────────────────────────────────
8481
- es.addEventListener('load-started', (e) => {
8482
- const { config } = JSON.parse(e.data);
8483
- loadState = { status: 'running', startTime: Date.now(), buckets: [], totalRequests: 0, totalErrors: 0, logs: [], script: config?.script || '', config, summary: null };
8484
- loadBuckets = [];
8485
- loadLogLines = [];
8486
- if (contextMode === 'load') { renderSidebar(); renderContent(); }
8487
- });
8619
+ es.addEventListener('load-started', (e) => {
8620
+ const { runId, config } = JSON.parse(e.data);
8621
+ loadState = { runId, status: 'running', startTime: Date.now(), buckets: [], totalRequests: 0, totalErrors: 0, logs: [], script: loadScriptDraft || '', config, summary: null };
8622
+ loadBuckets = [];
8623
+ loadLogLines = [];
8624
+ if (config) applyLoadConfigToFields(config);
8625
+ if (contextMode === 'load') { renderSidebar(); renderContent(); }
8626
+ });
8488
8627
 
8489
8628
  es.addEventListener('load-log', (e) => {
8490
8629
  const { line } = JSON.parse(e.data);
@@ -8502,18 +8641,20 @@ function connectSSE() {
8502
8641
  }
8503
8642
  });
8504
8643
 
8505
- es.addEventListener('load-progress', (e) => {
8506
- const { buckets, total, errors } = JSON.parse(e.data);
8507
- loadBuckets = buckets || [];
8508
- if (loadState) { loadState.totalRequests = total || 0; loadState.totalErrors = errors || 0; }
8509
- if (contextMode === 'load') { drawLoadCharts(); renderSidebar(); }
8510
- });
8511
-
8512
- es.addEventListener('load-done', (e) => {
8513
- const { status, summary } = JSON.parse(e.data);
8514
- if (loadState) { loadState.status = status; loadState.summary = summary || null; loadState.endTime = Date.now(); }
8515
- if (contextMode === 'load') { renderSidebar(); renderContent(); }
8516
- });
8644
+ es.addEventListener('load-progress', (e) => {
8645
+ const { buckets, total, errors } = JSON.parse(e.data);
8646
+ loadBuckets = buckets || [];
8647
+ if (loadState) loadState.buckets = loadBuckets;
8648
+ if (loadState) { loadState.totalRequests = total || 0; loadState.totalErrors = errors || 0; }
8649
+ if (contextMode === 'load') { drawLoadCharts(); renderSidebar(); }
8650
+ });
8651
+
8652
+ es.addEventListener('load-done', (e) => {
8653
+ const { status, summary } = JSON.parse(e.data);
8654
+ if (loadState) { loadState.status = status; loadState.summary = summary || null; loadState.endTime = Date.now(); }
8655
+ loadRefreshRuns();
8656
+ if (contextMode === 'load') { renderSidebar(); renderContent(); }
8657
+ });
8517
8658
 
8518
8659
  es.addEventListener('probe-run-started', (e) => {
8519
8660
  const payload = JSON.parse(e.data || '{}');