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.
- package/dist/scanner/index.d.ts +37 -0
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +151 -4
- package/dist/scanner/index.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +143 -1
- package/dist/server/index.js.map +1 -1
- package/dist/ui/dashboard.html +558 -29
- package/package.json +1 -1
package/dist/ui/dashboard.html
CHANGED
|
@@ -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
|
-
|
|
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 = '
|
|
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
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
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 =
|
|
2732
|
-
const _blCurrFPs =
|
|
2733
|
-
const _blCurrNoise =
|
|
2734
|
-
const _blCurrEnrich = Object.entries(
|
|
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 =
|
|
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:
|
|
2769
|
-
{ label: 'Mixed', count:
|
|
2770
|
-
{ label: 'Unstructured', count:
|
|
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 =
|
|
2919
|
+
window.__obsCatalog = catalog;
|
|
2783
2920
|
|
|
2784
|
-
const noisyRows =
|
|
2921
|
+
const noisyRows = topNoisy.map(i => {
|
|
2785
2922
|
const safePattern = escapeHtml(i.pattern).replace(/'/g, ''');
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
3041
|
+
const noisyCount = topNoisy.length;
|
|
2905
3042
|
const missingCount = v2Data.length;
|
|
2906
3043
|
const enrichCount = fieldGapEntries.length;
|
|
2907
|
-
const catalogCount =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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">▶</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">← Все фичи</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 =
|