viberadar 0.3.72 → 0.3.74

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.
@@ -19,6 +19,7 @@
19
19
  --green: #3fb950;
20
20
  --red: #f85149;
21
21
  --yellow: #e3b341;
22
+ --accent: #58a6ff;
22
23
  }
23
24
 
24
25
  body {
@@ -45,6 +46,15 @@
45
46
  header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
46
47
  .header-project { margin-left: auto; font-size: 13px; color: var(--muted); }
47
48
  .header-time { font-size: 12px; color: var(--dim); }
49
+ #rescanBtn { font-size: 16px; padding: 2px 6px; background: none; border: none; cursor: pointer; opacity: 0.7; transition: opacity 0.2s; }
50
+ #rescanBtn:hover { opacity: 1; }
51
+ #rescanBtn.spinning { animation: rescan-spin 0.8s linear infinite; opacity: 1; }
52
+ @keyframes rescan-spin { to { transform: rotate(360deg); } }
53
+ #rescanStatus { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid transparent; transition: all 0.4s; white-space: nowrap; }
54
+ #rescanStatus.status-idle { color: var(--dim); border-color: transparent; }
55
+ #rescanStatus.status-scanning { color: var(--yellow); border-color: rgba(227,179,65,0.3); }
56
+ #rescanStatus.status-done { color: var(--green); border-color: rgba(63,185,80,0.35); background: rgba(63,185,80,0.07); }
57
+ #rescanStatus.status-error { color: var(--red); border-color: rgba(248,81,73,0.3); }
48
58
  .header-agent-rights {
49
59
  font-size: 11px;
50
60
  color: var(--muted);
@@ -191,7 +201,7 @@
191
201
  .obs-priority-low { color: var(--green); }
192
202
  .obs-catalog { margin-top:10px; background: var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
193
203
  .obs-catalog h4 { font-size:12px; margin-bottom:6px; }
194
- .obs-cat-row { display:grid; grid-template-columns: 1.8fr .6fr .8fr .7fr .8fr 1fr; gap:8px; font-size:11px; color:var(--muted); padding:5px 0; border-bottom:1px dashed var(--border); }
204
+ .obs-cat-row { display:grid; grid-template-columns: 1.4fr .5fr .6fr .4fr 1.2fr .7fr auto; gap:6px; font-size:11px; color:var(--muted); padding:5px 0; border-bottom:1px dashed var(--border); }
195
205
  .obs-cat-row.head { color: var(--text); font-weight:600; text-transform:uppercase; font-size:10px; }
196
206
 
197
207
  /* ── Layout ──────────────────────────────────────────────────────────────── */
@@ -651,14 +661,48 @@
651
661
  transition: background 0.1s, color 0.1s, border-color 0.1s;
652
662
  }
653
663
  .file-row:hover .file-row-agent-btn { border-color: var(--accent); color: var(--accent); }
654
- .file-row-agent-btn:hover { background: var(--accent); color: #000 !important; border-color: var(--accent); }
664
+ .file-row-agent-btn:hover { background: var(--accent); color: var(--bg) !important; border-color: var(--accent); }
655
665
  .file-row-agent-btn.stale { border-color: var(--yellow); color: var(--yellow); }
656
- .file-row-agent-btn.stale:hover { background: var(--yellow) !important; color: #000 !important; }
666
+ .file-row-agent-btn.stale:hover { background: var(--yellow) !important; color: var(--bg) !important; }
657
667
  .file-row-fix-btn {
658
668
  background: var(--red) !important; border-color: var(--red) !important;
659
669
  color: #fff !important; font-weight: 600;
660
670
  }
661
671
  .file-row-fix-btn:hover { opacity: 0.85; }
672
+ .obs-action-btn {
673
+ display: inline-flex; align-items: center; gap: 3px;
674
+ padding: 2px 7px; font-size: 10px;
675
+ background: transparent; border: 1px solid var(--border); border-radius: 4px;
676
+ color: var(--dim); cursor: pointer; white-space: nowrap; flex-shrink: 0;
677
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
678
+ }
679
+ .obs-action-btn:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); }
680
+ .obs-batch-btn { border-color: var(--yellow); color: var(--yellow); }
681
+ .obs-batch-btn:hover { background: rgba(255,200,0,0.15); color: var(--yellow); border-color: var(--yellow); }
682
+ .obs-expand-btn { background:none; border:none; color:var(--muted); cursor:pointer; font-size:10px; padding:2px 4px; }
683
+ .obs-expand-btn:hover { color:var(--accent); }
684
+ .obs-detail { display:none; padding:6px 0 2px 0; border-top:1px dashed var(--border); margin-top:4px; }
685
+ .obs-detail.open { display:block; }
686
+ .obs-detail-list { max-height:220px; overflow-y:auto; display:flex; flex-direction:column; gap:2px; }
687
+ .obs-detail-item { display:flex; align-items:center; gap:6px; font-size:11px; color:var(--muted); padding:2px 0; }
688
+ .obs-detail-item input[type="checkbox"] { margin:0; flex-shrink:0; accent-color:var(--accent); }
689
+ .obs-detail-item span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
690
+ .obs-detail-bar { display:flex; align-items:center; gap:8px; margin-top:6px; padding-top:6px; border-top:1px dashed var(--border); }
691
+ .obs-run-selected { padding:3px 10px; font-size:11px; font-weight:600; background:var(--accent); color:var(--bg); border:none; border-radius:4px; cursor:pointer; }
692
+ .obs-run-selected:hover { opacity:0.85; }
693
+ .obs-run-selected:disabled { opacity:0.4; cursor:not-allowed; }
694
+ .obs-select-all { font-size:10px; color:var(--dim); cursor:pointer; background:none; border:none; }
695
+ .obs-select-all:hover { color:var(--accent); }
696
+ .obs-tier-badge { display:inline-block; padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; letter-spacing:0.5px; }
697
+ .obs-tier-critical { background:rgba(248,81,73,0.2); color:var(--red); }
698
+ .obs-tier-important { background:rgba(227,179,65,0.2); color:var(--yellow); }
699
+ .obs-tier-normal { background:rgba(139,148,158,0.15); color:var(--muted); }
700
+ .obs-fp-list { font-size:11px; color:var(--muted); margin:4px 0 0 18px; }
701
+ .obs-fp-item { display:flex; gap:6px; padding:1px 0; align-items:baseline; }
702
+ .obs-fp-type { color:var(--yellow); font-weight:600; white-space:nowrap; font-size:10px; }
703
+ .obs-fp-line { color:var(--dim); font-size:10px; flex-shrink:0; }
704
+ .obs-tier-group { margin-bottom:8px; }
705
+ .obs-tier-group-header { display:flex; align-items:center; gap:8px; padding:4px 0; font-size:12px; font-weight:600; color:var(--text); }
662
706
  .file-row-err-badge {
663
707
  display: inline-flex; align-items: center;
664
708
  font-size: 11px; padding: 1px 6px; border-radius: 10px;
@@ -768,7 +812,7 @@
768
812
  background: none; border: 1px solid var(--yellow); color: var(--yellow);
769
813
  cursor: pointer; font-size: 11px; padding: 2px 8px; border-radius: 4px;
770
814
  }
771
- .agent-panel-cancel:hover { background: var(--yellow); color: #000; }
815
+ .agent-panel-cancel:hover { background: var(--yellow); color: var(--bg); }
772
816
  .agent-queue-badge {
773
817
  font-size: 11px; color: var(--yellow); background: rgba(255,200,0,0.1);
774
818
  border: 1px solid var(--yellow); border-radius: 4px; padding: 2px 8px;
@@ -1031,6 +1075,8 @@
1031
1075
  <span class="header-project" id="projectName">—</span>
1032
1076
  <span class="header-time" id="scannedAt"></span>
1033
1077
  <span class="header-agent-rights" id="headerAgentRights" title="Права/режим выполнения агента">🔐 —</span>
1078
+ <button id="rescanBtn" onclick="rescan()" title="Пересканировать проект и обновить данные">🔄</button>
1079
+ <span id="rescanStatus" class="status-idle"></span>
1034
1080
  <button id="runAllBtn" onclick="runAllTests()" title="Запустить все unit и integration тесты">▶ Все тесты</button>
1035
1081
  <button id="termBtn" onclick="toggleAgentPanel()" title="Показать/скрыть терминал агента">📟 Terminal</button>
1036
1082
  <div style="position:relative">
@@ -2060,7 +2106,7 @@ async function reauthAgent() {
2060
2106
  await fetch('/api/agent-reauth', { method: 'POST' });
2061
2107
  }
2062
2108
 
2063
- async function runAgentTask(task, featureKey, filePath, selectedFilePaths) {
2109
+ async function runAgentTask(task, featureKey, filePath, selectedFilePaths, meta) {
2064
2110
  document.getElementById('agentPanel').classList.add('open');
2065
2111
  document.getElementById('termBtn').classList.add('term-active');
2066
2112
  if (!agentRunning) {
@@ -2074,10 +2120,60 @@ async function runAgentTask(task, featureKey, filePath, selectedFilePaths) {
2074
2120
  featureKey,
2075
2121
  filePath: filePath || undefined,
2076
2122
  selectedFilePaths: Array.isArray(selectedFilePaths) ? selectedFilePaths : undefined,
2123
+ meta: meta || undefined,
2077
2124
  }),
2078
2125
  });
2079
2126
  }
2080
2127
 
2128
+ // ─── Observability drill-down helpers ─────────────────────────────────────────
2129
+ function toggleObsDetail(id) {
2130
+ const el = document.getElementById('obs-detail-' + id);
2131
+ if (el) el.classList.toggle('open');
2132
+ }
2133
+
2134
+ function obsUpdateSelectedCount(groupId) {
2135
+ const container = document.getElementById('obs-detail-' + groupId);
2136
+ if (!container) return;
2137
+ const checked = container.querySelectorAll('input[type="checkbox"]:checked').length;
2138
+ const btn = container.querySelector('.obs-run-selected');
2139
+ if (btn) {
2140
+ btn.textContent = checked > 0 ? `исправить выбранные (${checked})` : 'исправить выбранные';
2141
+ btn.disabled = checked === 0;
2142
+ }
2143
+ }
2144
+
2145
+ function obsToggleAll(groupId) {
2146
+ const container = document.getElementById('obs-detail-' + groupId);
2147
+ if (!container) return;
2148
+ const boxes = container.querySelectorAll('input[type="checkbox"]');
2149
+ const allChecked = Array.from(boxes).every(b => b.checked);
2150
+ boxes.forEach(b => b.checked = !allChecked);
2151
+ obsUpdateSelectedCount(groupId);
2152
+ }
2153
+
2154
+ function obsRunSelected(groupId, task, baseMeta) {
2155
+ const container = document.getElementById('obs-detail-' + groupId);
2156
+ if (!container) return;
2157
+ const indices = [];
2158
+ container.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
2159
+ indices.push(parseInt(cb.dataset.idx, 10));
2160
+ });
2161
+ if (indices.length === 0) return;
2162
+ runAgentTask('obs-fix-selected', null, null, null, { ...baseMeta, catalogIndices: indices });
2163
+ }
2164
+
2165
+ function obsMissingRunSelected(groupId) {
2166
+ const container = document.getElementById('obs-detail-' + groupId);
2167
+ if (!container) return;
2168
+ const indices = [];
2169
+ container.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
2170
+ const idx = parseInt(cb.dataset.missingIdx, 10);
2171
+ if (!isNaN(idx)) indices.push(idx);
2172
+ });
2173
+ if (indices.length === 0) return;
2174
+ runAgentTask('obs-fix-selected', null, null, null, { missingLogIndices: indices, recommendationType: 'add event' });
2175
+ }
2176
+
2081
2177
  async function runTests(featureKey, testType) {
2082
2178
  document.getElementById('agentPanel').classList.add('open');
2083
2179
  document.getElementById('termBtn').classList.add('term-active');
@@ -2138,6 +2234,39 @@ function pluralFiles(n) {
2138
2234
  return 'файлов';
2139
2235
  }
2140
2236
 
2237
+ // ─── Rescan ───────────────────────────────────────────────────────────────────
2238
+ async function rescan() {
2239
+ const btn = document.getElementById('rescanBtn');
2240
+ const statusEl = document.getElementById('rescanStatus');
2241
+ if (!btn || btn.classList.contains('spinning')) return;
2242
+
2243
+ btn.classList.add('spinning');
2244
+ if (statusEl) { statusEl.className = 'status-scanning'; statusEl.textContent = 'Сканирую…'; }
2245
+
2246
+ try {
2247
+ const res = await fetch('/api/rescan', { method: 'POST' });
2248
+ if (!res.ok) throw new Error('rescan failed');
2249
+ D = await res.json();
2250
+ const t = new Date(D.scannedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
2251
+ document.getElementById('scannedAt').textContent = t;
2252
+ window.__obsCatalog = D.observability?.catalog || [];
2253
+ renderStats();
2254
+ renderSidebar();
2255
+ renderContent();
2256
+ if (statusEl) {
2257
+ statusEl.className = 'status-done';
2258
+ statusEl.textContent = '✓ актуально · ' + t;
2259
+ // Через 8 секунд приглушаем, но оставляем видимым
2260
+ setTimeout(() => { if (statusEl) { statusEl.className = 'status-idle'; statusEl.textContent = '✓ ' + t; } }, 8000);
2261
+ }
2262
+ } catch (e) {
2263
+ console.error('Rescan error:', e);
2264
+ if (statusEl) { statusEl.className = 'status-error'; statusEl.textContent = '⚠ ошибка сканирования'; }
2265
+ } finally {
2266
+ btn.classList.remove('spinning');
2267
+ }
2268
+ }
2269
+
2141
2270
  // ─── Init ─────────────────────────────────────────────────────────────────────
2142
2271
  async function init() {
2143
2272
  try {
@@ -2195,19 +2324,31 @@ function renderStats() {
2195
2324
 
2196
2325
  let items;
2197
2326
  if (contextMode === 'observability') {
2198
- const serviceSources = src.filter(m => m.type === 'service' || m.type === 'util' || m.type === 'other');
2199
- const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
2200
- const totalBytes = src.reduce((acc, m) => acc + (m.size || 0), 0);
2201
- const avgKb = src.length ? Math.round(totalBytes / src.length / 1024) : 0;
2202
- const noiseRatio = src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0;
2203
- const missingStructured = serviceSources.filter(m => !m.hasTests).length;
2204
- items = [
2205
- { v: serviceSources.length, l: 'Источники логов' },
2206
- { v: avgKb + ' KB', l: 'Средний объём/файл' },
2207
- { v: noiseRatio + '%', l: 'Коэффициент шума' },
2208
- { v: errorSignal, l: 'Сигнал ошибок', c: errorSignal ? '#f85149' : undefined },
2209
- { v: missingStructured, l: 'Не хватает полей', c: missingStructured ? '#e3b341' : undefined },
2210
- ];
2327
+ const o = D.observability;
2328
+ if (o) {
2329
+ const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2330
+ const structPct = Math.round(o.metrics.structured_completeness * 100);
2331
+ const v2 = o.missingCriticalLogsV2 || [];
2332
+ const missingCoverage = v2.length || o.missingCriticalLogs.length;
2333
+ const totalFPs = v2.reduce((s,m) => s + m.failurePoints.length, 0);
2334
+ const noisyCount = o.topNoisyPatterns.length;
2335
+ items = [
2336
+ { v: o.catalog.length, l: 'Источники логов' },
2337
+ { v: noiseRatio + '%', l: 'Коэффициент шума', c: noiseRatio > 30 ? '#f85149' : noiseRatio > 10 ? '#e3b341' : undefined },
2338
+ { v: structPct + '%', l: 'Структурированность', c: structPct < 50 ? '#f85149' : structPct < 80 ? '#e3b341' : undefined },
2339
+ { v: totalFPs || missingCoverage, l: totalFPs ? 'Точек отказа' : 'Нет покрытия', c: totalFPs > 10 ? '#f85149' : (totalFPs || missingCoverage) ? '#e3b341' : undefined },
2340
+ { v: noisyCount, l: 'Шумных паттернов', c: noisyCount ? '#e3b341' : undefined },
2341
+ ];
2342
+ } else {
2343
+ const serviceSources = src.filter(m => m.type === 'service' || m.type === 'util' || m.type === 'other');
2344
+ items = [
2345
+ { v: serviceSources.length, l: 'Источники логов' },
2346
+ { v: '—', l: 'Коэффициент шума' },
2347
+ { v: '—', l: 'Структурированность' },
2348
+ { v: '—', l: 'Нет покрытия' },
2349
+ { v: '—', l: 'Шумных паттернов' },
2350
+ ];
2351
+ }
2211
2352
  } else if (D.hasConfig && D.features) {
2212
2353
  const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
2213
2354
  items = [
@@ -2326,57 +2467,262 @@ function renderQaOnboarding() {
2326
2467
  }
2327
2468
 
2328
2469
  function renderObservability(c) {
2329
- const src = D.modules.filter(m => m.type !== 'test');
2330
- const sourceGroups = [
2331
- { label: 'Сервисы', count: src.filter(m => m.type === 'service').length },
2332
- { label: 'Утилиты', count: src.filter(m => m.type === 'util').length },
2333
- { label: 'Прочее', count: src.filter(m => m.type === 'other').length },
2470
+ const o = D.observability;
2471
+ if (!o) {
2472
+ c.innerHTML = `<div class="onboarding-block"><h3>Наблюдаемость</h3><p>Данные анализа логов недоступны. Пересканируй проект.</p></div>`;
2473
+ return;
2474
+ }
2475
+
2476
+ const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2477
+ const structPct = Math.round(o.metrics.structured_completeness * 100);
2478
+ const actionPct = Math.round(o.metrics.error_actionability * 100);
2479
+ const coveragePct = Math.round(o.metrics.coverage_of_key_flows * 100);
2480
+
2481
+ function metricColor(val, goodThreshold, warnThreshold, invert) {
2482
+ // invert=true: higher is worse (noise), invert=false: higher is better (coverage)
2483
+ if (invert) return val > warnThreshold ? 'var(--red)' : val > goodThreshold ? 'var(--yellow)' : 'var(--green)';
2484
+ return val < warnThreshold ? 'var(--red)' : val < goodThreshold ? 'var(--yellow)' : 'var(--green)';
2485
+ }
2486
+
2487
+ const formatLabels = { structured: 'structured', mixed: 'mixed', unstructured: 'unstructured' };
2488
+ const sourceByFormat = [
2489
+ { label: 'Structured', count: o.catalog.filter(c => c.format === 'structured').length, color: 'var(--green)' },
2490
+ { label: 'Mixed', count: o.catalog.filter(c => c.format === 'mixed').length, color: 'var(--yellow)' },
2491
+ { label: 'Unstructured', count: o.catalog.filter(c => c.format === 'unstructured').length, color: 'var(--red)' },
2334
2492
  ].filter(x => x.count > 0);
2335
- const errorSignal = Object.values(D.testErrors || {}).reduce((acc, v) => acc + (v?.failed || 0), 0);
2336
- const missingStructured = src.filter(m => (m.type === 'service' || m.type === 'util') && !m.hasTests).slice(0, 8);
2337
- const noisy = [...src]
2338
- .sort((a, b) => (b.size || 0) - (a.size || 0))
2339
- .slice(0, 5)
2340
- .map(m => ({ name: m.name, rel: m.relativePath, kb: Math.round((m.size || 0) / 1024) }));
2493
+
2494
+ const recLabels = {
2495
+ 'suppress': 'убрать',
2496
+ 'downgrade level': 'понизить уровень',
2497
+ 'enrich fields': 'обогатить поля',
2498
+ 'add event': 'добавить событие',
2499
+ };
2500
+ const hasAgent = !!D.agent;
2501
+
2502
+ // Store catalog for buttons to reference by index (avoids inline JSON in onclick)
2503
+ window.__obsCatalog = o.catalog;
2504
+
2505
+ const noisyRows = o.topNoisyPatterns.slice(0, 8).map(i => {
2506
+ const safePattern = escapeHtml(i.pattern).replace(/'/g, '&#39;');
2507
+ const btn = hasAgent
2508
+ ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-suppress-pattern',null,null,null,{pattern:'${safePattern}',recommendation:'${i.recommendation}'})">убрать</button>`
2509
+ : '';
2510
+ return `
2511
+ <div class="obs-list-item">
2512
+ <span class="obs-priority-${i.priority}" style="flex-shrink:0">[${i.priority}]</span>
2513
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(i.pattern)}">${escapeHtml(i.pattern)}</span>
2514
+ <strong style="flex-shrink:0;color:var(--red)">×${i.count} → ${recLabels[i.recommendation] || i.recommendation}</strong>
2515
+ ${btn}
2516
+ </div>`;
2517
+ }).join('') || '<div class="obs-sub">Шумных паттернов не обнаружено</div>';
2518
+
2519
+ // ── Missing critical logs (V2: grouped by risk tier with failure points) ──
2520
+ const v2Data = o.missingCriticalLogsV2 || [];
2521
+ window.__obsMissingV2 = v2Data;
2522
+ const fpTypeLabels = {
2523
+ 'empty-catch':'пустой catch','catch-no-log':'catch без лога','promise-catch-no-log':'.catch без лога',
2524
+ 'http-no-error-handling':'HTTP без обработки','db-no-error-handling':'DB без обработки',
2525
+ 'throw-no-log':'throw без лога','error-check-no-log':'if(err) без лога',
2526
+ };
2527
+ let missingSection = '';
2528
+ if (v2Data.length > 0) {
2529
+ const tierLabels = { critical:'Критичные', important:'Важные', normal:'Обычные' };
2530
+ const tiers = ['critical','important','normal'];
2531
+ const tierSections = tiers.map(tier => {
2532
+ const items = v2Data.filter(m => m.riskTier === tier);
2533
+ if (!items.length) return '';
2534
+ const groupId = 'missing-' + tier;
2535
+ const expandBtn = hasAgent ? `<button class="obs-expand-btn" onclick="event.stopPropagation();toggleObsDetail('${groupId}')">▼</button>` : '';
2536
+ const totalFPs = items.reduce((s,m) => s + m.failurePoints.length, 0);
2537
+ const detailItems = items.map(m => {
2538
+ const globalIdx = v2Data.indexOf(m);
2539
+ const fpCount = m.failurePoints.length;
2540
+ const fpPreview = m.failurePoints.slice(0,3).map(fp =>
2541
+ `<div class="obs-fp-item"><span class="obs-fp-type">${fpTypeLabels[fp.type]||fp.type}</span><span class="obs-fp-line">~${fp.lineApprox}</span></div>`
2542
+ ).join('');
2543
+ const moreCount = fpCount > 3 ? `<div class="obs-fp-item" style="color:var(--dim)">...и ещё ${fpCount-3}</div>` : '';
2544
+ const covBadge = m.hasAnyWarnError ? '<span style="font-size:10px;color:var(--dim)" title="Есть warn/error, но не все точки покрыты">частично</span>' : '';
2545
+ return `<label class="obs-detail-item" style="flex-wrap:wrap">
2546
+ <input type="checkbox" data-missing-idx="${globalIdx}" onchange="obsUpdateSelectedCount('${groupId}')">
2547
+ <span title="${escapeHtml(m.modulePath)}" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(m.modulePath.split('/').slice(-2).join('/'))}</span>
2548
+ <span style="color:var(--dim);flex-shrink:0;font-size:10px">${escapeHtml(m.roleHint)}</span>
2549
+ <span style="color:var(--yellow);flex-shrink:0">${fpCount} точек</span>
2550
+ ${covBadge}
2551
+ ${fpPreview || moreCount ? `<div class="obs-fp-list" style="width:100%">${fpPreview}${moreCount}</div>` : ''}
2552
+ </label>`;
2553
+ }).join('');
2554
+ const addBtn = hasAgent ? `<button class="obs-run-selected" disabled onclick="obsMissingRunSelected('${groupId}')">добавить логи выбранным</button>` : '';
2555
+ const detail = hasAgent ? `
2556
+ <div id="obs-detail-${groupId}" class="obs-detail">
2557
+ <div class="obs-detail-bar" style="border-top:none;padding-top:0;margin-bottom:4px">
2558
+ <button class="obs-select-all" onclick="obsToggleAll('${groupId}')">выбрать все / снять</button>
2559
+ </div>
2560
+ <div class="obs-detail-list">${detailItems}</div>
2561
+ <div class="obs-detail-bar">${addBtn}</div>
2562
+ </div>` : '';
2563
+ return `<div class="obs-tier-group">
2564
+ <div class="obs-tier-group-header">
2565
+ <span class="obs-tier-badge obs-tier-${tier}">${tierLabels[tier]}</span>
2566
+ <span>${items.length} модулей, ${totalFPs} точек отказа</span>
2567
+ ${expandBtn}
2568
+ </div>
2569
+ ${detail}
2570
+ </div>`;
2571
+ }).join('');
2572
+ missingSection = tierSections || '<div class="obs-sub" style="color:var(--green)">Критичные сценарии покрыты</div>';
2573
+ } else {
2574
+ missingSection = '<div class="obs-sub" style="color:var(--green)">Критичные сценарии покрыты</div>';
2575
+ }
2576
+
2577
+ const fieldGaps = o.fieldGaps || {};
2578
+ const fieldGapEntries = Object.entries(fieldGaps).filter(([,v]) => v > 0).sort((a,b) => b[1] - a[1]);
2579
+ const fieldGapRows = fieldGapEntries.map(([name, count]) => {
2580
+ const groupId = 'field-' + name.replace(/[^a-z0-9]/gi, '_');
2581
+ const affectedItems = o.catalog.filter(c => (c.missingFields||[]).includes(name));
2582
+ const expandBtn = hasAgent ? `<button class="obs-expand-btn" onclick="event.stopPropagation();toggleObsDetail('${groupId}')">▼</button>` : '';
2583
+ const detailItems = affectedItems.map(ci => {
2584
+ const catIdx = o.catalog.indexOf(ci);
2585
+ return `<label class="obs-detail-item"><input type="checkbox" data-idx="${catIdx}" onchange="obsUpdateSelectedCount('${groupId}')"><span title="${escapeHtml(ci.modulePath)}">${escapeHtml(ci.modulePath.split('/').slice(-2).join('/'))}</span><span style="color:var(--dim);flex-shrink:0">${ci.format}</span></label>`;
2586
+ }).join('');
2587
+ const detail = hasAgent ? `
2588
+ <div id="obs-detail-${groupId}" class="obs-detail">
2589
+ <div class="obs-detail-bar" style="border-top:none;padding-top:0;margin-bottom:4px">
2590
+ <button class="obs-select-all" onclick="obsToggleAll('${groupId}')">выбрать все / снять</button>
2591
+ </div>
2592
+ <div class="obs-detail-list">${detailItems}</div>
2593
+ <div class="obs-detail-bar">
2594
+ <button class="obs-run-selected" disabled onclick="obsRunSelected('${groupId}','obs-fix-selected',{fieldName:'${escapeHtml(name)}'})">обогатить выбранные</button>
2595
+ </div>
2596
+ </div>` : '';
2597
+ return `<div>
2598
+ <div class="obs-list-item">
2599
+ <span><code>${escapeHtml(name)}</code></span>
2600
+ <strong style="color:${count > 20 ? 'var(--red)' : count > 5 ? 'var(--yellow)' : 'var(--muted)'}">${count} пропусков</strong>
2601
+ ${expandBtn}
2602
+ </div>
2603
+ ${detail}
2604
+ </div>`;
2605
+ }).join('') || '<div class="obs-sub" style="color:var(--green)">Все обязательные поля на месте</div>';
2606
+
2607
+ const catalogRows = o.catalog.slice(0, 15).map((i, idx) => {
2608
+ const missing = (i.missingFields || []);
2609
+ const missingStr = missing.length ? missing.join(', ') : '—';
2610
+ const btn = hasAgent
2611
+ ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-fix-module',null,null,null,{catalogIndex:${idx}})">исправить</button>`
2612
+ : '';
2613
+ return `
2614
+ <div class="obs-cat-row">
2615
+ <span title="${escapeHtml(i.modulePath)}">${escapeHtml(i.modulePath.split('/').slice(-2).join('/'))}</span>
2616
+ <span>${i.level}</span>
2617
+ <span style="color:${i.format==='structured'?'var(--green)':i.format==='mixed'?'var(--yellow)':'var(--red)'}">${i.format}</span>
2618
+ <span style="color:${missing.length > 4 ? 'var(--red)' : missing.length > 0 ? 'var(--yellow)' : 'var(--green)'}">${missing.length}/8</span>
2619
+ <span title="${escapeHtml(missingStr)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(missingStr)}</span>
2620
+ <span style="color:${i.recommendation==='suppress'?'var(--red)':i.recommendation==='add event'?'var(--yellow)':'var(--muted)'}">${recLabels[i.recommendation] || i.recommendation}</span>
2621
+ ${btn}
2622
+ </div>`}).join('');
2341
2623
 
2342
2624
  c.innerHTML = `
2343
2625
  <div class="onboarding-block">
2344
2626
  <h3>Наблюдаемость: что это?</h3>
2345
- <p>Экран для контроля отношения сигнал/шум: откуда идут логи, где растёт объём, сколько шума и какие поля/сигналы нужно структурировать в первую очередь.</p>
2627
+ <p>Аудит покрытия логами: что добавить, что убрать, что обогатить на основе статического анализа лог-вызовов.</p>
2628
+ </div>
2629
+
2630
+ <div class="obs-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
2631
+ <div class="obs-card">
2632
+ <div class="obs-title">Коэффициент шума</div>
2633
+ <div class="obs-value" style="color:${metricColor(noiseRatio,10,30,true)}">${noiseRatio}%</div>
2634
+ <div class="obs-sub">Доля шумных лог-вызовов из всех.</div>
2635
+ </div>
2636
+ <div class="obs-card">
2637
+ <div class="obs-title">Структурированность</div>
2638
+ <div class="obs-value" style="color:${metricColor(structPct,80,50,false)}">${structPct}%</div>
2639
+ <div class="obs-sub">Логи с обязательными полями (module, event, traceId).</div>
2640
+ </div>
2641
+ <div class="obs-card">
2642
+ <div class="obs-title">Actionable ошибки</div>
2643
+ <div class="obs-value" style="color:${metricColor(actionPct,80,50,false)}">${actionPct}%</div>
2644
+ <div class="obs-sub">ERROR-логи с контекстом для диагностики.</div>
2645
+ </div>
2646
+ <div class="obs-card">
2647
+ <div class="obs-title">Покрытие сценариев</div>
2648
+ <div class="obs-value" style="color:${metricColor(coveragePct,80,50,false)}">${coveragePct}%</div>
2649
+ <div class="obs-sub">Модули с хотя бы одним warn/error событием.</div>
2650
+ </div>
2346
2651
  </div>
2347
2652
 
2348
- <div class="obs-grid">
2653
+ <div class="obs-grid" style="grid-template-columns:1fr 1fr 1fr;margin-bottom:12px">
2349
2654
  <div class="obs-card">
2350
- <div class="obs-title">Источники логов</div>
2655
+ <div class="obs-title">Источники по формату</div>
2351
2656
  <div class="obs-list">
2352
- ${sourceGroups.map(g => `<div class="obs-list-item"><span>${g.label}</span><strong>${g.count}</strong></div>`).join('') || '<div class="obs-sub">Нет данных</div>'}
2657
+ ${sourceByFormat.map(g => `
2658
+ <div class="obs-list-item">
2659
+ <span style="color:${g.color}">${g.label}</span>
2660
+ <strong>${g.count}</strong>
2661
+ </div>`).join('') || '<div class="obs-sub">Нет данных</div>'}
2353
2662
  </div>
2354
2663
  </div>
2355
-
2356
2664
  <div class="obs-card">
2357
- <div class="obs-title">Объём логов (топ файлов)</div>
2665
+ <div class="obs-title">Классификация логов</div>
2358
2666
  <div class="obs-list">
2359
- ${noisy.map(n => `<div class="obs-list-item"><span title="${n.rel}">${n.name}</span><strong>${n.kb} KB</strong></div>`).join('') || '<div class="obs-sub">Нет файлов</div>'}
2667
+ <div class="obs-list-item"><span style="color:var(--red)">Мусор (убрать)</span><strong>${o.classification.trash}</strong></div>
2668
+ <div class="obs-list-item"><span style="color:var(--green)">Полезные</span><strong>${o.classification.useful}</strong></div>
2669
+ <div class="obs-list-item"><span style="color:var(--blue)">Критичные</span><strong>${o.classification.critical}</strong></div>
2360
2670
  </div>
2361
2671
  </div>
2362
-
2363
2672
  <div class="obs-card">
2364
- <div class="obs-title">Коэффициент шума</div>
2365
- <div class="obs-value">${src.length ? Math.round((src.filter(m => !m.hasTests).length / src.length) * 100) : 0}%</div>
2366
- <div class="obs-sub">Доля источников без тестового контроля (прокси-метрика шумного логирования).</div>
2673
+ <div class="obs-title">Рекомендации</div>
2674
+ <div class="obs-list" style="gap:2px">
2675
+ ${['suppress','enrich fields','add event','downgrade level'].map(rec => {
2676
+ const items = o.catalog.filter(c => c.recommendation === rec);
2677
+ if (!items.length) return '';
2678
+ const groupId = 'rec-' + rec.replace(/\s+/g, '-');
2679
+ const expandBtn = hasAgent ? `<button class="obs-expand-btn" onclick="event.stopPropagation();toggleObsDetail('${groupId}')">▼</button>` : '';
2680
+ const detailItems = items.map((ci, i) => {
2681
+ const catIdx = o.catalog.indexOf(ci);
2682
+ const mf = (ci.missingFields||[]).join(', ') || '—';
2683
+ return `<label class="obs-detail-item"><input type="checkbox" data-idx="${catIdx}" onchange="obsUpdateSelectedCount('${groupId}')"><span title="${escapeHtml(ci.modulePath)}">${escapeHtml(ci.modulePath.split('/').slice(-2).join('/'))}</span><span style="color:var(--dim);flex-shrink:0">${ci.format}</span></label>`;
2684
+ }).join('');
2685
+ const detail = hasAgent ? `
2686
+ <div id="obs-detail-${groupId}" class="obs-detail">
2687
+ <div class="obs-detail-bar" style="border-top:none;padding-top:0;margin-bottom:4px">
2688
+ <button class="obs-select-all" onclick="obsToggleAll('${groupId}')">выбрать все / снять</button>
2689
+ </div>
2690
+ <div class="obs-detail-list">${detailItems}</div>
2691
+ <div class="obs-detail-bar">
2692
+ <button class="obs-run-selected" disabled onclick="obsRunSelected('${groupId}','obs-fix-selected',{recommendationType:'${rec}'})">исправить выбранные</button>
2693
+ </div>
2694
+ </div>` : '';
2695
+ return `<div>
2696
+ <div class="obs-list-item"><span>${recLabels[rec]}</span><strong>${items.length}</strong>${expandBtn}</div>
2697
+ ${detail}
2698
+ </div>`;
2699
+ }).join('')}
2700
+ </div>
2367
2701
  </div>
2702
+ </div>
2368
2703
 
2704
+ <div class="obs-grid" style="grid-template-columns:1fr 1fr;margin-bottom:12px">
2369
2705
  <div class="obs-card">
2370
- <div class="obs-title">Сигнал ошибок</div>
2371
- <div class="obs-value" style="color:${errorSignal ? 'var(--red)' : 'var(--green)'}">${errorSignal}</div>
2372
- <div class="obs-sub">Сумма упавших тестов из последнего прогона как индикатор нестабильности сигналов.</div>
2706
+ <div class="obs-title">Что убрать — шумные паттерны</div>
2707
+ <div class="obs-list" style="gap:4px">${noisyRows}</div>
2373
2708
  </div>
2709
+ <div class="obs-card">
2710
+ <div class="obs-title">Что добавить — нет критичных логов</div>
2711
+ <div class="obs-list" style="gap:4px">${missingSection}</div>
2712
+ </div>
2713
+ </div>
2374
2714
 
2375
- <div class="obs-card" style="grid-column:1 / -1">
2376
- <div class="obs-title">Недостающие структурированные поля (кандидаты)</div>
2377
- <div class="obs-list">
2378
- ${missingStructured.map(m => `<div class="obs-list-item"><span>${m.relativePath}</span><strong>нужна схема</strong></div>`).join('') || '<div class="obs-sub">Явных кандидатов не найдено</div>'}
2379
- </div>
2715
+ <div class="obs-card" style="margin-bottom:12px">
2716
+ <div class="obs-title">Что обогатить пробелы по полям</div>
2717
+ <div class="obs-sub" style="margin-bottom:8px">Обязательные поля по стандарту: service, env, trace_id, request_id, event_name, outcome, error_code (warn/error), user_id.</div>
2718
+ <div class="obs-list">${fieldGapRows}</div>
2719
+ </div>
2720
+
2721
+ <div class="obs-card" style="margin-bottom:12px">
2722
+ <div class="obs-title">Каталог источников логов (топ 15)</div>
2723
+ <div class="obs-catalog" style="margin-top:8px;border:none;padding:0">
2724
+ <div class="obs-cat-row head"><span>модуль</span><span>уровень</span><span>формат</span><span>пробелы</span><span>не хватает</span><span>действие</span><span></span></div>
2725
+ ${catalogRows || '<div class="obs-row">Логи не найдены</div>'}
2380
2726
  </div>
2381
2727
  </div>`;
2382
2728
  }
@@ -2410,9 +2756,19 @@ function renderObservabilityOverview(c) {
2410
2756
  `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} · x${i.count} → <b>${i.recommendation}</b></div>`
2411
2757
  ).join('') || '<div class="obs-row">Нет шумных паттернов</div>';
2412
2758
 
2413
- const missing = (o.missingCriticalLogs || []).slice(0, 5).map(i =>
2414
- `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} → <b>${i.recommendation}</b></div>`
2415
- ).join('') || '<div class="obs-row">Критичные логи покрыты</div>';
2759
+ const v2Missing = o.missingCriticalLogsV2 || [];
2760
+ let missing;
2761
+ if (v2Missing.length) {
2762
+ const tierLabels = { critical: '🔴 крит', important: '🟡 важн', normal: '⚪ обычн' };
2763
+ missing = v2Missing.slice(0, 8).map(m =>
2764
+ `<div class="obs-row"><span class="obs-tier-badge obs-tier-${m.riskTier}">${tierLabels[m.riskTier]}</span> ${escapeHtml(m.modulePath)} · ${m.failurePoints.length} точек отказа</div>`
2765
+ ).join('');
2766
+ if (v2Missing.length > 8) missing += `<div class="obs-row" style="color:var(--dim)">...и ещё ${v2Missing.length - 8}</div>`;
2767
+ } else {
2768
+ missing = (o.missingCriticalLogs || []).slice(0, 5).map(i =>
2769
+ `<div class="obs-row"><span class="obs-priority-${i.priority}">[${i.priority}]</span> ${escapeHtml(i.pattern)} → <b>${i.recommendation}</b></div>`
2770
+ ).join('') || '<div class="obs-row">Критичные логи покрыты</div>';
2771
+ }
2416
2772
 
2417
2773
  const catalogRows = (o.catalog || []).slice(0, 10).map(i =>
2418
2774
  `<div class="obs-cat-row"><span>${escapeHtml(i.modulePath)}</span><span>${i.level}</span><span>${i.format}</span><span>${i.frequency}</span><span>${escapeHtml(i.owner)}</span><span>${i.recommendation}</span></div>`
@@ -3062,7 +3418,7 @@ function renderUnmappedDetail(c) {
3062
3418
  <div style="padding:0 0 12px;display:flex;gap:8px;flex-wrap:wrap">
3063
3419
  ${D.agent ? `<button id="runAgentUnmapped" style="
3064
3420
  padding:7px 14px; background:var(--blue); border:none;
3065
- border-radius:6px; color:#000; font-size:12px; font-weight:700; cursor:pointer;
3421
+ border-radius:6px; color:var(--bg); font-size:12px; font-weight:700; cursor:pointer;
3066
3422
  ">▶ Разобрать через ${D.agent === 'claude' ? 'Claude Code' : 'Codex'}</button>` : ''}
3067
3423
  <button id="copyUnmappedDrill" style="
3068
3424
  padding:7px 14px; background:var(--bg-card); border:1px solid var(--border);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberadar",
3
- "version": "0.3.72",
3
+ "version": "0.3.74",
4
4
  "description": "Live module map with test coverage for vibecoding projects",
5
5
  "main": "./dist/cli.js",
6
6
  "bin": {