viberadar 0.3.80 → 0.3.82

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.
@@ -389,6 +389,61 @@
389
389
  .feature-progress-fill { height: 100%; border-radius: 3px; transition: width 0.4s; }
390
390
  .feature-progress-label { font-size: 11px; color: var(--muted); white-space: nowrap; }
391
391
 
392
+ /* ── Documentation screen ────────────────────────────────────────────────── */
393
+ .doc-kpi-strip {
394
+ display: flex; gap: 24px; padding: 12px 16px; margin-bottom: 16px;
395
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
396
+ }
397
+ .doc-kpi { display: flex; flex-direction: column; align-items: center; gap: 2px; }
398
+ .doc-kpi-val { font-size: 20px; font-weight: 700; color: var(--text); }
399
+ .doc-kpi-lbl { font-size: 11px; color: var(--muted); }
400
+ .doc-feature-card { cursor: pointer; }
401
+ .doc-agent-btn {
402
+ font-size: 11px; padding: 4px 10px; border-radius: 6px;
403
+ border: 1px solid var(--border); background: var(--bg-hover); color: var(--text);
404
+ cursor: pointer; transition: background 0.15s;
405
+ }
406
+ .doc-agent-btn:hover { background: var(--blue); color: #fff; border-color: var(--blue); }
407
+ .doc-changed-section {
408
+ margin-bottom: 16px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
409
+ }
410
+ .doc-changed-header {
411
+ padding: 8px 12px; background: var(--bg-card); color: var(--text);
412
+ font-size: 13px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 8px;
413
+ }
414
+ .doc-changed-arrow { font-size: 10px; transition: transform 0.2s; }
415
+ .doc-changed-section.open .doc-changed-arrow { transform: rotate(90deg); }
416
+ .doc-changed-list { display: none; padding: 8px 12px; }
417
+ .doc-changed-section.open .doc-changed-list { display: block; }
418
+ .doc-changed-file {
419
+ font-size: 12px; color: var(--yellow); padding: 3px 0;
420
+ font-family: 'SF Mono', 'Fira Code', monospace;
421
+ }
422
+ .doc-content {
423
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
424
+ padding: 20px 24px; line-height: 1.65; color: var(--text);
425
+ }
426
+ .doc-content .doc-h { margin: 20px 0 8px 0; color: var(--text); font-weight: 600; }
427
+ .doc-content h1.doc-h { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
428
+ .doc-content h2.doc-h { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
429
+ .doc-content h3.doc-h { font-size: 15px; }
430
+ .doc-content .doc-p { margin: 8px 0; font-size: 14px; }
431
+ .doc-content .doc-list { margin: 8px 0; padding-left: 24px; font-size: 14px; }
432
+ .doc-content .doc-list li { margin: 4px 0; }
433
+ .doc-content .doc-code {
434
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
435
+ padding: 12px 16px; overflow-x: auto; font-size: 13px; margin: 12px 0;
436
+ }
437
+ .doc-content .doc-inline-code {
438
+ background: var(--bg); border: 1px solid var(--border); border-radius: 3px;
439
+ padding: 1px 5px; font-size: 12px;
440
+ }
441
+ .back-btn {
442
+ background: none; border: 1px solid var(--border); color: var(--muted);
443
+ padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
444
+ }
445
+ .back-btn:hover { color: var(--text); border-color: var(--dim); }
446
+
392
447
  /* ── No config banner ────────────────────────────────────────────────────── */
393
448
  .no-config {
394
449
  display: flex;
@@ -1363,15 +1418,19 @@ function switchObsTab(tabId) {
1363
1418
  const modeStore = {
1364
1419
  qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1365
1420
  observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1421
+ docs: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false },
1366
1422
  };
1367
1423
 
1368
1424
  function getModeFromPath(pathname = window.location.pathname) {
1369
1425
  if (pathname.startsWith('/radar/observability')) return 'observability';
1426
+ if (pathname.startsWith('/radar/docs')) return 'docs';
1370
1427
  return 'qa';
1371
1428
  }
1372
1429
 
1373
1430
  function routePathForMode(mode) {
1374
- return mode === 'observability' ? '/radar/observability' : '/radar/qa';
1431
+ if (mode === 'observability') return '/radar/observability';
1432
+ if (mode === 'docs') return '/radar/docs';
1433
+ return '/radar/qa';
1375
1434
  }
1376
1435
 
1377
1436
  function setModeRoute(mode, replace = false) {
@@ -1408,8 +1467,8 @@ function switchMode(nextMode) {
1408
1467
  saveModeState(contextMode);
1409
1468
  contextMode = nextMode;
1410
1469
  restoreModeState(contextMode);
1411
- if (contextMode === 'observability') {
1412
- view = 'files';
1470
+ if (contextMode === 'observability' || contextMode === 'docs') {
1471
+ view = 'features';
1413
1472
  drillFeatureKey = null;
1414
1473
  drillTestType = null;
1415
1474
  activePanelKey = null;
@@ -2618,6 +2677,7 @@ function renderModeSwitch() {
2618
2677
  const modes = [
2619
2678
  { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2620
2679
  { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2680
+ { key: 'docs', label: 'Документация', hint: 'Актуальность, генерация, обновление' },
2621
2681
  ];
2622
2682
  root.innerHTML = modes.map(m => `
2623
2683
  <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
@@ -2646,6 +2706,16 @@ function renderSidebar() {
2646
2706
  return;
2647
2707
  }
2648
2708
 
2709
+ if (contextMode === 'docs') {
2710
+ tabs.style.display = 'none';
2711
+ extra.innerHTML = `
2712
+ <div class="sidebar-label">Документация</div>
2713
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45">
2714
+ Статус и актуальность документации по фичам.
2715
+ </div>`;
2716
+ return;
2717
+ }
2718
+
2649
2719
  tabs.style.display = 'flex';
2650
2720
  document.querySelectorAll('.view-tab').forEach(t =>
2651
2721
  t.classList.toggle('active', t.dataset.view === view)
@@ -2690,6 +2760,10 @@ function renderContent() {
2690
2760
  renderObservability(c);
2691
2761
  return;
2692
2762
  }
2763
+ if (contextMode === 'docs') {
2764
+ renderDocumentation(c);
2765
+ return;
2766
+ }
2693
2767
 
2694
2768
  if (view === 'features') {
2695
2769
  if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
@@ -2711,13 +2785,76 @@ function renderObservability(c) {
2711
2785
  return;
2712
2786
  }
2713
2787
 
2714
- const noiseRatio = Math.round(o.metrics.noise_ratio * 100);
2715
- const structPct = Math.round(o.metrics.structured_completeness * 100);
2716
- const actionPct = Math.round(o.metrics.error_actionability * 100);
2717
- const coveragePct = Math.round(o.metrics.coverage_of_key_flows * 100);
2788
+ // Feature-based routing (mirrors QA coverage pattern)
2789
+ if (D.hasConfig && D.features && o.byFeature && !drillFeatureKey) {
2790
+ renderObsFeatureCards(c);
2791
+ return;
2792
+ }
2793
+ if (drillFeatureKey) {
2794
+ renderObsFeatureDetail(c);
2795
+ return;
2796
+ }
2797
+
2798
+ renderObsGlobalDetail(c);
2799
+ }
2800
+
2801
+ function renderObsGlobalDetail(c, filterFeatureKey) {
2802
+ const o = D.observability;
2803
+ if (!o) return;
2804
+
2805
+ // If filtering by feature, scope data to that feature's modules
2806
+ let catalog = o.catalog;
2807
+ let missingV2 = o.missingCriticalLogsV2 || [];
2808
+ let topNoisy = o.topNoisyPatterns;
2809
+ let fieldGapsData = o.fieldGaps || {};
2810
+ let metricsData = o.metrics;
2811
+
2812
+ if (filterFeatureKey) {
2813
+ const isUnmapped = filterFeatureKey === '__unmapped__';
2814
+ catalog = o.catalog.filter(ci => isUnmapped ? (!ci.featureKeys || ci.featureKeys.length === 0) : (ci.featureKeys || []).includes(filterFeatureKey));
2815
+ // Build set of module paths in this feature for missingV2 filtering
2816
+ const featureModulePaths = new Set(
2817
+ (D.modules || []).filter(m => {
2818
+ if (isUnmapped) return !m.featureKeys || m.featureKeys.length === 0;
2819
+ return (m.featureKeys || []).includes(filterFeatureKey);
2820
+ }).map(m => m.relativePath)
2821
+ );
2822
+ missingV2 = (o.missingCriticalLogsV2 || []).filter(m => featureModulePaths.has(m.modulePath));
2823
+ // Re-derive noisy patterns from filtered catalog
2824
+ const noisyAgg = new Map();
2825
+ for (const ci of catalog) {
2826
+ for (const nm of (ci.noisyMessages || [])) {
2827
+ noisyAgg.set(nm, (noisyAgg.get(nm) || 0) + 1);
2828
+ }
2829
+ }
2830
+ topNoisy = Array.from(noisyAgg.entries())
2831
+ .sort((a, b) => b[1] - a[1])
2832
+ .slice(0, 8)
2833
+ .map(([pattern, count], i) => ({
2834
+ pattern, count,
2835
+ priority: i < 3 ? 'high' : i < 6 ? 'medium' : 'low',
2836
+ recommendation: count >= 3 ? 'suppress' : 'downgrade level',
2837
+ }));
2838
+ // Re-derive field gaps from filtered catalog
2839
+ fieldGapsData = {};
2840
+ for (const ci of catalog) {
2841
+ for (const f of (ci.missingFields || [])) {
2842
+ fieldGapsData[f] = (fieldGapsData[f] || 0) + 1;
2843
+ }
2844
+ }
2845
+ // Use per-feature metrics if available
2846
+ if (!isUnmapped && o.byFeature) {
2847
+ const bf = o.byFeature.find(f => f.key === filterFeatureKey);
2848
+ if (bf) metricsData = bf.metrics;
2849
+ }
2850
+ }
2851
+
2852
+ const noiseRatio = Math.round(metricsData.noise_ratio * 100);
2853
+ const structPct = Math.round(metricsData.structured_completeness * 100);
2854
+ const actionPct = Math.round(metricsData.error_actionability * 100);
2855
+ const coveragePct = Math.round(metricsData.coverage_of_key_flows * 100);
2718
2856
 
2719
2857
  function metricColor(val, goodThreshold, warnThreshold, invert) {
2720
- // invert=true: higher is worse (noise), invert=false: higher is better (coverage)
2721
2858
  if (invert) return val > warnThreshold ? 'var(--red)' : val > goodThreshold ? 'var(--yellow)' : 'var(--green)';
2722
2859
  return val < warnThreshold ? 'var(--red)' : val < goodThreshold ? 'var(--yellow)' : 'var(--green)';
2723
2860
  }
@@ -2728,13 +2865,13 @@ function renderObservability(c) {
2728
2865
  try { _bl = JSON.parse(localStorage.getItem('vr_obs_bl_' + _bsKey) || 'null'); } catch(e) {}
2729
2866
 
2730
2867
  // Pre-compute current snapshot values (before tierSections so _d is in scope)
2731
- const _blCurrMissing = (o.missingCriticalLogsV2 || []).length;
2732
- const _blCurrFPs = (o.missingCriticalLogsV2 || []).reduce((s, m) => s + (m.failurePoints || []).length, 0);
2733
- const _blCurrNoise = (o.topNoisyPatterns || []).length;
2734
- const _blCurrEnrich = Object.entries(o.fieldGaps || {}).filter(([, v]) => v > 0).length;
2868
+ const _blCurrMissing = missingV2.length;
2869
+ const _blCurrFPs = missingV2.reduce((s, m) => s + (m.failurePoints || []).length, 0);
2870
+ const _blCurrNoise = topNoisy.length;
2871
+ const _blCurrEnrich = Object.entries(fieldGapsData).filter(([, v]) => v > 0).length;
2735
2872
  const _blCurrTiers = {};
2736
2873
  ['critical', 'important', 'normal'].forEach(tier => {
2737
- const items = (o.missingCriticalLogsV2 || []).filter(m => m.riskTier === tier);
2874
+ const items = missingV2.filter(m => m.riskTier === tier);
2738
2875
  _blCurrTiers[tier] = { modules: items.length, fps: items.reduce((s, m) => s + (m.failurePoints || []).length, 0) };
2739
2876
  });
2740
2877
 
@@ -2765,9 +2902,9 @@ function renderObservability(c) {
2765
2902
 
2766
2903
  const formatLabels = { structured: 'structured', mixed: 'mixed', unstructured: 'unstructured' };
2767
2904
  const sourceByFormat = [
2768
- { label: 'Structured', count: o.catalog.filter(c => c.format === 'structured').length, color: 'var(--green)' },
2769
- { label: 'Mixed', count: o.catalog.filter(c => c.format === 'mixed').length, color: 'var(--yellow)' },
2770
- { label: 'Unstructured', count: o.catalog.filter(c => c.format === 'unstructured').length, color: 'var(--red)' },
2905
+ { label: 'Structured', count: catalog.filter(c => c.format === 'structured').length, color: 'var(--green)' },
2906
+ { label: 'Mixed', count: catalog.filter(c => c.format === 'mixed').length, color: 'var(--yellow)' },
2907
+ { label: 'Unstructured', count: catalog.filter(c => c.format === 'unstructured').length, color: 'var(--red)' },
2771
2908
  ].filter(x => x.count > 0);
2772
2909
 
2773
2910
  const recLabels = {
@@ -2779,9 +2916,9 @@ function renderObservability(c) {
2779
2916
  const hasAgent = !!D.agent;
2780
2917
 
2781
2918
  // Store catalog for buttons to reference by index (avoids inline JSON in onclick)
2782
- window.__obsCatalog = o.catalog;
2919
+ window.__obsCatalog = catalog;
2783
2920
 
2784
- const noisyRows = o.topNoisyPatterns.map(i => {
2921
+ const noisyRows = topNoisy.map(i => {
2785
2922
  const safePattern = escapeHtml(i.pattern).replace(/'/g, '&#39;');
2786
2923
  const btn = hasAgent
2787
2924
  ? `<button class="obs-action-btn" onclick="event.stopPropagation();runAgentTask('obs-suppress-pattern',null,null,null,{pattern:'${safePattern}',recommendation:'${i.recommendation}'})">убрать</button>`
@@ -2796,7 +2933,7 @@ function renderObservability(c) {
2796
2933
  }).join('') || '<div class="obs-sub">Шумных паттернов не обнаружено</div>';
2797
2934
 
2798
2935
  // ── Missing critical logs (V2: grouped by risk tier with failure points) ──
2799
- const v2Data = o.missingCriticalLogsV2 || [];
2936
+ const v2Data = missingV2;
2800
2937
  window.__obsMissingV2 = v2Data;
2801
2938
  const fpTypeLabels = {
2802
2939
  'empty-catch':'пустой catch','catch-no-log':'catch без лога','promise-catch-no-log':'.catch без лога',
@@ -2854,14 +2991,14 @@ function renderObservability(c) {
2854
2991
  missingSection = '<div class="obs-sub" style="color:var(--green)">Критичные сценарии покрыты</div>';
2855
2992
  }
2856
2993
 
2857
- const fieldGaps = o.fieldGaps || {};
2994
+ const fieldGaps = fieldGapsData;
2858
2995
  const fieldGapEntries = Object.entries(fieldGaps).filter(([,v]) => v > 0).sort((a,b) => b[1] - a[1]);
2859
2996
  const fieldGapRows = fieldGapEntries.map(([name, count]) => {
2860
2997
  const groupId = 'field-' + name.replace(/[^a-z0-9]/gi, '_');
2861
- const affectedItems = o.catalog.filter(c => (c.missingFields||[]).includes(name));
2998
+ const affectedItems = catalog.filter(c => (c.missingFields||[]).includes(name));
2862
2999
  const expandBtn = hasAgent ? `<button class="obs-expand-btn" onclick="event.stopPropagation();toggleObsDetail('${groupId}')">▼</button>` : '';
2863
3000
  const detailItems = affectedItems.map(ci => {
2864
- const catIdx = o.catalog.indexOf(ci);
3001
+ const catIdx = catalog.indexOf(ci);
2865
3002
  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>`;
2866
3003
  }).join('');
2867
3004
  const detail = hasAgent ? `
@@ -2884,7 +3021,7 @@ function renderObservability(c) {
2884
3021
  </div>`;
2885
3022
  }).join('') || '<div class="obs-sub" style="color:var(--green)">Все обязательные поля на месте</div>';
2886
3023
 
2887
- const catalogRows = o.catalog.map((i, idx) => {
3024
+ const catalogRows = catalog.map((i, idx) => {
2888
3025
  const missing = (i.missingFields || []);
2889
3026
  const missingStr = missing.length ? missing.join(', ') : '—';
2890
3027
  const btn = hasAgent
@@ -2901,10 +3038,10 @@ function renderObservability(c) {
2901
3038
  ${btn}
2902
3039
  </div>`}).join('');
2903
3040
 
2904
- const noisyCount = o.topNoisyPatterns.length;
3041
+ const noisyCount = topNoisy.length;
2905
3042
  const missingCount = v2Data.length;
2906
3043
  const enrichCount = fieldGapEntries.length;
2907
- const catalogCount = o.catalog.length;
3044
+ const catalogCount = catalog.length;
2908
3045
 
2909
3046
  // ── Build progress banner comparing to baseline ──
2910
3047
  const _blItems = [];
@@ -2931,11 +3068,29 @@ function renderObservability(c) {
2931
3068
  <button onclick="obsResetBaseline('${_bsKey}')">обновить</button>
2932
3069
  </div>`;
2933
3070
 
3071
+ // Feature drill-down header
3072
+ const _featureHeader = filterFeatureKey ? (() => {
3073
+ const isUnmapped = filterFeatureKey === '__unmapped__';
3074
+ const bf = !isUnmapped && o.byFeature ? o.byFeature.find(f => f.key === filterFeatureKey) : null;
3075
+ const label = isUnmapped ? 'Unmapped' : (bf ? bf.label : filterFeatureKey);
3076
+ const color = isUnmapped ? '#e3b341' : (bf ? bf.color : '#484f58');
3077
+ const score = bf ? bf.score : null;
3078
+ return `<div class="feature-detail-header">
3079
+ <button class="back-btn" onclick="setFeatureDrill(null)">← Все фичи</button>
3080
+ <span class="feature-detail-name">
3081
+ <span class="feature-dot" style="background:${color}"></span>
3082
+ ${escapeHtml(label)}
3083
+ </span>
3084
+ ${score != null ? `<span style="font-size:20px;font-weight:700;color:${covColor(score)}">${score}%</span>` : ''}
3085
+ </div>`;
3086
+ })() : '';
3087
+
2934
3088
  c.innerHTML = `
2935
- <div class="onboarding-block">
3089
+ ${_featureHeader}
3090
+ ${!filterFeatureKey ? `<div class="onboarding-block">
2936
3091
  <h3>Наблюдаемость: что это?</h3>
2937
3092
  <p>Аудит покрытия логами: что добавить, что убрать, что обогатить — на основе статического анализа лог-вызовов.</p>
2938
- </div>
3093
+ </div>` : ''}
2939
3094
 
2940
3095
  ${_progressBanner}
2941
3096
  ${_baselineStrip}
@@ -3024,12 +3179,12 @@ function renderObservability(c) {
3024
3179
  <div class="obs-title">Рекомендации</div>
3025
3180
  <div class="obs-list" style="gap:2px">
3026
3181
  ${['suppress','enrich fields','add event','downgrade level'].map(rec => {
3027
- const items = o.catalog.filter(c => c.recommendation === rec);
3182
+ const items = catalog.filter(c => c.recommendation === rec);
3028
3183
  if (!items.length) return '';
3029
3184
  const groupId = 'rec-' + rec.replace(/\s+/g, '-');
3030
3185
  const expandBtn = hasAgent ? `<button class="obs-expand-btn" onclick="event.stopPropagation();toggleObsDetail('${groupId}')">▼</button>` : '';
3031
3186
  const detailItems = items.map((ci, i) => {
3032
- const catIdx = o.catalog.indexOf(ci);
3187
+ const catIdx = catalog.indexOf(ci);
3033
3188
  const mf = (ci.missingFields||[]).join(', ') || '—';
3034
3189
  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>`;
3035
3190
  }).join('');
@@ -3255,6 +3410,127 @@ function renderObservability(c) {
3255
3410
  switchObsTab(obsActiveTab);
3256
3411
  }
3257
3412
 
3413
+ function renderObsFeatureDetail(c) {
3414
+ D.currentFeatureKey = drillFeatureKey;
3415
+ renderObsGlobalDetail(c, drillFeatureKey);
3416
+ }
3417
+
3418
+ function renderObsFeatureCards(c) {
3419
+ const o = D.observability;
3420
+ if (!o || !o.byFeature) return;
3421
+
3422
+ const q = searchQuery.toLowerCase();
3423
+ const list = o.byFeature.filter(f =>
3424
+ !q || f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q)
3425
+ );
3426
+
3427
+ // Global KPI strip (compact)
3428
+ const gNoiseRatio = Math.round(o.metrics.noise_ratio * 100);
3429
+ const gStructPct = Math.round(o.metrics.structured_completeness * 100);
3430
+ const gActionPct = Math.round(o.metrics.error_actionability * 100);
3431
+ const gCoveragePct = Math.round(o.metrics.coverage_of_key_flows * 100);
3432
+ function mc(val, good, warn, inv) {
3433
+ if (inv) return val > warn ? 'var(--red)' : val > good ? 'var(--yellow)' : 'var(--green)';
3434
+ return val < warn ? 'var(--red)' : val < good ? 'var(--yellow)' : 'var(--green)';
3435
+ }
3436
+
3437
+ const globalStrip = `
3438
+ <div class="obs-kpi-strip" style="margin-bottom:16px">
3439
+ <div class="obs-kpi"><span class="obs-kpi-value" style="color:${mc(gNoiseRatio,10,30,true)}">${gNoiseRatio}%</span><span class="obs-kpi-label">Шум</span></div>
3440
+ <div class="obs-kpi"><span class="obs-kpi-value" style="color:${mc(gStructPct,80,50,false)}">${gStructPct}%</span><span class="obs-kpi-label">Структ.</span></div>
3441
+ <div class="obs-kpi"><span class="obs-kpi-value" style="color:${mc(gActionPct,80,50,false)}">${gActionPct}%</span><span class="obs-kpi-label">Actionable</span></div>
3442
+ <div class="obs-kpi"><span class="obs-kpi-value" style="color:${mc(gCoveragePct,80,50,false)}">${gCoveragePct}%</span><span class="obs-kpi-label">Покрытие</span></div>
3443
+ </div>`;
3444
+
3445
+ c.innerHTML = globalStrip + '<div class="features-grid" id="obsGrid"></div>';
3446
+ const grid = document.getElementById('obsGrid');
3447
+
3448
+ list.forEach(f => {
3449
+ const card = document.createElement('div');
3450
+ card.className = 'feature-card';
3451
+
3452
+ // Per-feature baseline
3453
+ const bsKey = String(f.key).replace(/[^a-z0-9_-]/gi, '_');
3454
+ let bl = null;
3455
+ try { bl = JSON.parse(localStorage.getItem('vr_obs_bl_' + bsKey) || 'null'); } catch(e) {}
3456
+ const scoreDelta = bl && bl.score != null && f.score !== bl.score
3457
+ ? (() => {
3458
+ const diff = f.score - bl.score;
3459
+ const good = diff > 0;
3460
+ const color = good ? '#3fb950' : '#f85149';
3461
+ const sign = diff > 0 ? '+' : '';
3462
+ return `<span style="color:${color};font-size:11px;font-weight:700;margin-left:4px">(${sign}${diff})</span>`;
3463
+ })() : '';
3464
+
3465
+ // Mini KPI
3466
+ const n = Math.round(f.metrics.noise_ratio * 100);
3467
+ const s = Math.round(f.metrics.structured_completeness * 100);
3468
+ const a = Math.round(f.metrics.error_actionability * 100);
3469
+ const cv = Math.round(f.metrics.coverage_of_key_flows * 100);
3470
+
3471
+ // Issue counts for badges
3472
+ const issues = f.noisyPatternCount + f.missingCriticalCount + f.fieldGapCount;
3473
+
3474
+ card.innerHTML = `
3475
+ <div class="feature-accent" style="background:${f.color}"></div>
3476
+ <div class="feature-body">
3477
+ <div class="feature-title">
3478
+ <span>${escapeHtml(f.label)}</span>
3479
+ <span class="feature-file-count">${f.catalogCount} лог-модулей</span>
3480
+ </div>
3481
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
3482
+ <span style="font-size:24px;font-weight:700;line-height:1;color:${covColor(f.score)}">${f.score}%</span>
3483
+ ${scoreDelta}
3484
+ <div style="flex:1">
3485
+ <div style="font-size:10px;color:var(--muted);margin-bottom:3px;text-transform:uppercase;letter-spacing:0.4px">Observability Score</div>
3486
+ <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
3487
+ <div style="width:${f.score}%;height:100%;background:${covColor(f.score)};border-radius:2px;transition:width 0.4s"></div>
3488
+ </div>
3489
+ </div>
3490
+ </div>
3491
+ <div style="display:flex;gap:12px;font-size:11px;color:var(--dim);margin-bottom:8px">
3492
+ <span title="Шум">🔇 <span style="color:${mc(n,10,30,true)}">${n}%</span></span>
3493
+ <span title="Структурированность">📋 <span style="color:${mc(s,80,50,false)}">${s}%</span></span>
3494
+ <span title="Actionable ошибки">🎯 <span style="color:${mc(a,80,50,false)}">${a}%</span></span>
3495
+ <span title="Покрытие сценариев">🛡️ <span style="color:${mc(cv,80,50,false)}">${cv}%</span></span>
3496
+ </div>
3497
+ ${issues > 0 ? `<div style="font-size:11px;color:var(--muted)">
3498
+ ${f.noisyPatternCount ? `<span style="color:var(--red)">${f.noisyPatternCount} шум</span> ` : ''}
3499
+ ${f.missingCriticalCount ? `<span style="color:var(--yellow)">${f.missingCriticalCount} без логов</span> ` : ''}
3500
+ ${f.failurePointCount ? `<span style="color:var(--yellow)">${f.failurePointCount} точек отказа</span> ` : ''}
3501
+ ${f.fieldGapCount ? `<span style="color:var(--muted)">${f.fieldGapCount} пробелов</span>` : ''}
3502
+ </div>` : '<div style="font-size:11px;color:var(--green)">Всё ок</div>'}
3503
+ </div>`;
3504
+
3505
+ card.onclick = () => setFeatureDrill(f.key);
3506
+ card.onmousedown = (e) => { if (e.button === 1) e.preventDefault(); };
3507
+ card.onauxclick = (e) => {
3508
+ if (e.button !== 1) return;
3509
+ e.preventDefault();
3510
+ openFeatureInNewTab(f.key);
3511
+ };
3512
+ grid.appendChild(card);
3513
+ });
3514
+
3515
+ // Unmapped card
3516
+ const unmappedCatalog = o.catalog.filter(ci => !ci.featureKeys || ci.featureKeys.length === 0);
3517
+ if (unmappedCatalog.length > 0) {
3518
+ const uCard = document.createElement('div');
3519
+ uCard.className = 'feature-card unmapped-card';
3520
+ uCard.innerHTML = `
3521
+ <div class="feature-accent" style="background:#e3b341"></div>
3522
+ <div class="feature-body">
3523
+ <div class="feature-title">
3524
+ <span>Unmapped</span>
3525
+ <span class="feature-file-count">${unmappedCatalog.length} лог-модулей</span>
3526
+ </div>
3527
+ <div style="font-size:12px;color:var(--dim);margin-top:4px">Модули с логами, не привязанные к фичам</div>
3528
+ </div>`;
3529
+ uCard.onclick = () => setFeatureDrill('__unmapped__');
3530
+ grid.appendChild(uCard);
3531
+ }
3532
+ }
3533
+
3258
3534
  function backToFeatureDetail() {
3259
3535
  drillTestType = null;
3260
3536
  activePanelKey = null;
@@ -3329,6 +3605,258 @@ function renderObservabilityOverview(c) {
3329
3605
  </div>`;
3330
3606
  }
3331
3607
 
3608
+ // ─── Documentation screen ─────────────────────────────────────────────────────
3609
+
3610
+ function renderDocumentation(c) {
3611
+ const doc = D.documentation;
3612
+ if (!doc) {
3613
+ c.innerHTML = `<div class="onboarding-block"><h3>Документация</h3><p>Данные документации недоступны. Убедитесь, что в проекте есть <code>viberadar.config.json</code> с фичами и пересканируйте проект.</p></div>`;
3614
+ return;
3615
+ }
3616
+ if (drillFeatureKey) renderDocFeatureDetail(c);
3617
+ else renderDocFeatureCards(c);
3618
+ }
3619
+
3620
+ function renderDocFeatureCards(c) {
3621
+ const doc = D.documentation;
3622
+ if (!doc || !doc.features.length) {
3623
+ c.innerHTML = `<div class="onboarding-block"><h3>Документация</h3><p>Фичи не найдены. Добавьте фичи в viberadar.config.json.</p></div>`;
3624
+ return;
3625
+ }
3626
+
3627
+ const q = searchQuery.toLowerCase();
3628
+ const filtered = doc.features.filter(f => !q || f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q));
3629
+
3630
+ const docPct = doc.totalFeatures > 0 ? Math.round(((doc.freshCount + doc.staleCount) / doc.totalFeatures) * 100) : 0;
3631
+
3632
+ let html = `
3633
+ <div class="doc-kpi-strip">
3634
+ <div class="doc-kpi"><span class="doc-kpi-val">${doc.totalFeatures}</span><span class="doc-kpi-lbl">Всего фич</span></div>
3635
+ <div class="doc-kpi"><span class="doc-kpi-val" style="color:var(--green)">${doc.freshCount}</span><span class="doc-kpi-lbl">Актуальных</span></div>
3636
+ <div class="doc-kpi"><span class="doc-kpi-val" style="color:var(--yellow)">${doc.staleCount}</span><span class="doc-kpi-lbl">Устаревших</span></div>
3637
+ <div class="doc-kpi"><span class="doc-kpi-val" style="color:var(--red)">${doc.missingCount}</span><span class="doc-kpi-lbl">Без доки</span></div>
3638
+ <div class="doc-kpi"><span class="doc-kpi-val">${docPct}%</span><span class="doc-kpi-lbl">Задокументировано</span></div>
3639
+ </div>
3640
+ <div class="features-grid">`;
3641
+
3642
+ for (const f of filtered) {
3643
+ const statusColor = !f.docExists ? 'var(--red)' : f.isStale ? 'var(--yellow)' : 'var(--green)';
3644
+ const statusText = !f.docExists ? 'Отсутствует' : f.isStale ? `Устарела (${f.changedFilesSinceDoc.length} файл.)` : 'Актуальна';
3645
+ const statusDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor};margin-right:6px"></span>`;
3646
+ const lastUpd = f.lastUpdated ? new Date(f.lastUpdated).toLocaleDateString('ru-RU') : '—';
3647
+ const freshFiles = f.docExists ? (f.sourceFileCount - f.changedFilesSinceDoc.length) : 0;
3648
+ const progressPct = f.sourceFileCount > 0 ? Math.round((freshFiles / f.sourceFileCount) * 100) : 0;
3649
+ const progressColor = !f.docExists ? 'var(--red)' : f.isStale ? 'var(--yellow)' : 'var(--green)';
3650
+
3651
+ const agentTask = !f.docExists ? 'generate-docs' : f.isStale ? 'update-docs' : null;
3652
+ const agentLabel = !f.docExists ? 'Написать доку' : f.isStale ? 'Обновить доку' : '';
3653
+ const agentBtn = agentTask
3654
+ ? `<button class="agent-card-btn doc-agent-btn" data-task="${agentTask}" data-key="${f.key}" style="margin-top:8px">${agentLabel}</button>`
3655
+ : `<div style="margin-top:8px;font-size:11px;color:var(--green)">Документация актуальна</div>`;
3656
+
3657
+ html += `
3658
+ <div class="feature-card doc-feature-card" data-key="${f.key}">
3659
+ <div class="feature-accent" style="background:${f.color}"></div>
3660
+ <div class="feature-body">
3661
+ <div class="feature-title">
3662
+ <span>${escapeHtml(f.label)}</span>
3663
+ <span class="feature-file-count">${f.sourceFileCount} файл.</span>
3664
+ </div>
3665
+ <div style="display:flex;align-items:center;gap:4px;margin:6px 0;font-size:12px">
3666
+ ${statusDot}<span style="color:${statusColor};font-weight:600">${statusText}</span>
3667
+ </div>
3668
+ <div style="font-size:11px;color:var(--muted);margin-bottom:6px">Обновлено: ${lastUpd}</div>
3669
+ <div class="feature-progress-bar">
3670
+ <div class="feature-progress-fill" style="width:${f.docExists ? progressPct : 0}%;background:${progressColor}"></div>
3671
+ </div>
3672
+ <div class="feature-progress-label">${f.docExists ? (f.isStale ? `${f.changedFilesSinceDoc.length} из ${f.sourceFileCount} изменены` : `${f.sourceFileCount} из ${f.sourceFileCount} актуальны`) : 'Документация отсутствует'}</div>
3673
+ ${agentBtn}
3674
+ </div>
3675
+ </div>`;
3676
+ }
3677
+ html += '</div>';
3678
+ c.innerHTML = html;
3679
+
3680
+ // Card click → drill-down
3681
+ c.querySelectorAll('.doc-feature-card').forEach(card => {
3682
+ card.addEventListener('click', (e) => {
3683
+ if (e.target.closest('.doc-agent-btn')) return;
3684
+ setFeatureDrill(card.dataset.key);
3685
+ });
3686
+ });
3687
+
3688
+ // Agent button clicks
3689
+ c.querySelectorAll('.doc-agent-btn').forEach(btn => {
3690
+ btn.addEventListener('click', (e) => {
3691
+ e.stopPropagation();
3692
+ runAgentTask(btn.dataset.task, btn.dataset.key);
3693
+ });
3694
+ });
3695
+ }
3696
+
3697
+ // Lightweight markdown → HTML renderer
3698
+ function renderMarkdownToHtml(md) {
3699
+ let html = '';
3700
+ const lines = md.split('\n');
3701
+ let inCodeBlock = false;
3702
+ let codeBlockContent = '';
3703
+ let inList = false;
3704
+ let listType = '';
3705
+
3706
+ for (let i = 0; i < lines.length; i++) {
3707
+ const line = lines[i];
3708
+
3709
+ // Code blocks
3710
+ if (line.trimStart().startsWith('```')) {
3711
+ if (inCodeBlock) {
3712
+ html += `<pre class="doc-code"><code>${escapeHtml(codeBlockContent.trimEnd())}</code></pre>`;
3713
+ codeBlockContent = '';
3714
+ inCodeBlock = false;
3715
+ } else {
3716
+ if (inList) { html += listType === 'ul' ? '</ul>' : '</ol>'; inList = false; }
3717
+ inCodeBlock = true;
3718
+ }
3719
+ continue;
3720
+ }
3721
+ if (inCodeBlock) {
3722
+ codeBlockContent += line + '\n';
3723
+ continue;
3724
+ }
3725
+
3726
+ // Headings
3727
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
3728
+ if (headingMatch) {
3729
+ if (inList) { html += listType === 'ul' ? '</ul>' : '</ol>'; inList = false; }
3730
+ const level = headingMatch[1].length;
3731
+ html += `<h${level} class="doc-h">${inlineFormat(headingMatch[2])}</h${level}>`;
3732
+ continue;
3733
+ }
3734
+
3735
+ // Unordered list
3736
+ if (/^\s*[-*]\s+/.test(line)) {
3737
+ if (!inList || listType !== 'ul') {
3738
+ if (inList) html += listType === 'ul' ? '</ul>' : '</ol>';
3739
+ html += '<ul class="doc-list">';
3740
+ inList = true;
3741
+ listType = 'ul';
3742
+ }
3743
+ html += `<li>${inlineFormat(line.replace(/^\s*[-*]\s+/, ''))}</li>`;
3744
+ continue;
3745
+ }
3746
+
3747
+ // Ordered list
3748
+ if (/^\s*\d+\.\s+/.test(line)) {
3749
+ if (!inList || listType !== 'ol') {
3750
+ if (inList) html += listType === 'ul' ? '</ul>' : '</ol>';
3751
+ html += '<ol class="doc-list">';
3752
+ inList = true;
3753
+ listType = 'ol';
3754
+ }
3755
+ html += `<li>${inlineFormat(line.replace(/^\s*\d+\.\s+/, ''))}</li>`;
3756
+ continue;
3757
+ }
3758
+
3759
+ // End list
3760
+ if (inList && line.trim() === '') {
3761
+ html += listType === 'ul' ? '</ul>' : '</ol>';
3762
+ inList = false;
3763
+ }
3764
+
3765
+ // Empty line
3766
+ if (line.trim() === '') continue;
3767
+
3768
+ // Paragraph
3769
+ if (!inList) {
3770
+ html += `<p class="doc-p">${inlineFormat(line)}</p>`;
3771
+ }
3772
+ }
3773
+
3774
+ if (inCodeBlock) {
3775
+ html += `<pre class="doc-code"><code>${escapeHtml(codeBlockContent.trimEnd())}</code></pre>`;
3776
+ }
3777
+ if (inList) html += listType === 'ul' ? '</ul>' : '</ol>';
3778
+ return html;
3779
+ }
3780
+
3781
+ function inlineFormat(text) {
3782
+ let s = escapeHtml(text);
3783
+ s = s.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');
3784
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
3785
+ s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
3786
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--blue)">$1</a>');
3787
+ return s;
3788
+ }
3789
+
3790
+ let docContentCache = {};
3791
+
3792
+ async function renderDocFeatureDetail(c) {
3793
+ const doc = D.documentation;
3794
+ const f = doc?.features.find(x => x.key === drillFeatureKey);
3795
+ if (!f) {
3796
+ c.innerHTML = `<div class="onboarding-block"><p>Фича не найдена.</p></div>`;
3797
+ return;
3798
+ }
3799
+
3800
+ const statusColor = !f.docExists ? 'var(--red)' : f.isStale ? 'var(--yellow)' : 'var(--green)';
3801
+ const statusText = !f.docExists ? 'Отсутствует' : f.isStale ? `Устарела (${f.changedFilesSinceDoc.length} файл.)` : 'Актуальна';
3802
+ const statusDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor};margin-right:6px"></span>`;
3803
+ const lastUpd = f.lastUpdated ? new Date(f.lastUpdated).toLocaleDateString('ru-RU') : '—';
3804
+
3805
+ const agentTask = !f.docExists ? 'generate-docs' : f.isStale ? 'update-docs' : null;
3806
+ const agentLabel = !f.docExists ? 'Сгенерировать документацию' : f.isStale ? `Обновить документацию (${f.changedFilesSinceDoc.length} файл.)` : '';
3807
+
3808
+ let changedFilesHtml = '';
3809
+ if (f.isStale && f.changedFilesSinceDoc.length > 0) {
3810
+ changedFilesHtml = `
3811
+ <div class="doc-changed-section">
3812
+ <div class="doc-changed-header" onclick="this.parentElement.classList.toggle('open')">
3813
+ Изменённые файлы (${f.changedFilesSinceDoc.length})
3814
+ <span class="doc-changed-arrow">&#9654;</span>
3815
+ </div>
3816
+ <div class="doc-changed-list">
3817
+ ${f.changedFilesSinceDoc.map(fp => `<div class="doc-changed-file">${escapeHtml(fp)}</div>`).join('')}
3818
+ </div>
3819
+ </div>`;
3820
+ }
3821
+
3822
+ let docContentHtml = '';
3823
+ if (f.docExists) {
3824
+ // Fetch content if not cached
3825
+ if (!docContentCache[f.key]) {
3826
+ try {
3827
+ const resp = await fetch('/api/docs/content?feature=' + encodeURIComponent(f.key));
3828
+ const data = await resp.json();
3829
+ if (data.exists && data.content) docContentCache[f.key] = data.content;
3830
+ } catch {}
3831
+ }
3832
+ const raw = docContentCache[f.key] || '';
3833
+ docContentHtml = raw ? `<div class="doc-content">${renderMarkdownToHtml(raw)}</div>` : '<div style="color:var(--muted);padding:16px">Не удалось загрузить содержимое документации.</div>';
3834
+ } else {
3835
+ docContentHtml = `<div style="color:var(--muted);padding:16px;text-align:center">Документация ещё не создана. Нажмите кнопку ниже, чтобы сгенерировать.</div>`;
3836
+ }
3837
+
3838
+ c.innerHTML = `
3839
+ <div style="margin-bottom:16px">
3840
+ <button class="back-btn" id="docBackBtn">&#8592; Все фичи</button>
3841
+ </div>
3842
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
3843
+ <div style="width:4px;height:28px;border-radius:2px;background:${f.color}"></div>
3844
+ <h2 style="margin:0;font-size:18px;color:var(--text)">${escapeHtml(f.label)}</h2>
3845
+ <div style="display:flex;align-items:center;font-size:13px">${statusDot}<span style="color:${statusColor};font-weight:600">${statusText}</span></div>
3846
+ <span style="font-size:12px;color:var(--muted)">Обновлено: ${lastUpd}</span>
3847
+ </div>
3848
+ ${agentTask ? `<div style="margin-bottom:16px"><button class="agent-card-btn doc-detail-agent-btn" data-task="${agentTask}" data-key="${f.key}">${agentLabel}</button></div>` : ''}
3849
+ ${changedFilesHtml}
3850
+ ${docContentHtml}`;
3851
+
3852
+ document.getElementById('docBackBtn').onclick = () => backToFeatures();
3853
+
3854
+ const detailAgentBtn = c.querySelector('.doc-detail-agent-btn');
3855
+ if (detailAgentBtn) {
3856
+ detailAgentBtn.onclick = () => runAgentTask(detailAgentBtn.dataset.task, detailAgentBtn.dataset.key);
3857
+ }
3858
+ }
3859
+
3332
3860
  function renderFeatureCards(c) {
3333
3861
  if (!D.hasConfig || !D.features) {
3334
3862
  c.innerHTML = `
@@ -4435,6 +4963,7 @@ async function refreshData() {
4435
4963
  try {
4436
4964
  const res = await fetch('/api/data');
4437
4965
  D = await res.json();
4966
+ docContentCache = {};
4438
4967
 
4439
4968
  // Update header timestamp
4440
4969
  document.getElementById('scannedAt').textContent =