viberadar 0.3.113 → 0.3.115

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.
@@ -1354,6 +1354,72 @@
1354
1354
  .e2e-screenshot-modal { position:fixed; inset:0; background:rgba(0,0,0,0.8); z-index:9999; display:flex; align-items:center; justify-content:center; cursor:pointer; }
1355
1355
  .e2e-screenshot-modal img { max-width:90vw; max-height:90vh; border-radius:8px; }
1356
1356
  .e2e-plan-indicator { font-size:10px; color:#d2a8ff; margin-left:4px; }
1357
+
1358
+ /* ─── Service Map styles ──────────────────────────────────────────────────── */
1359
+ .svc-sidebar-tabs { display:flex; flex-direction:column; gap:4px; padding:0 6px; }
1360
+ .svc-sidebar-tab { background:none; border:1px solid var(--border); color:var(--text); padding:6px 10px; border-radius:6px; cursor:pointer; font-size:12px; text-align:left; transition:all 0.15s; }
1361
+ .svc-sidebar-tab:hover { background:var(--surface); }
1362
+ .svc-sidebar-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
1363
+
1364
+ .svc-graph-wrap { position:relative; width:100%; height:calc(100vh - 160px); overflow:hidden; border:1px solid var(--border); border-radius:8px; background:var(--bg); }
1365
+ .svc-graph-svg { width:100%; height:100%; }
1366
+ .svc-graph-svg text { font-family:inherit; }
1367
+
1368
+ .svc-node-group { cursor:pointer; }
1369
+ .svc-node-rect { rx:8; ry:8; stroke-width:1.5; transition:stroke 0.15s, filter 0.15s; }
1370
+ .svc-node-group:hover .svc-node-rect { stroke:var(--accent); filter:drop-shadow(0 0 6px rgba(88,166,255,0.4)); }
1371
+ .svc-node-icon { font-size:18px; pointer-events:none; }
1372
+ .svc-node-label { fill:var(--text); font-size:11px; pointer-events:none; }
1373
+ .svc-node-category { fill:var(--muted); font-size:9px; pointer-events:none; }
1374
+
1375
+ .svc-edge { fill:none; stroke:var(--border); stroke-width:1.5; }
1376
+ .svc-edge.critical { stroke:#f85149; stroke-width:2.5; }
1377
+ .svc-edge-label { fill:var(--muted); font-size:9px; }
1378
+ .svc-edge.highlighted { stroke:var(--accent); stroke-width:2; }
1379
+
1380
+ .svc-group-label { fill:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1px; }
1381
+
1382
+ .svc-legend { position:absolute; bottom:12px; left:12px; display:flex; gap:12px; font-size:11px; color:var(--muted); background:var(--surface); padding:6px 12px; border-radius:6px; border:1px solid var(--border); }
1383
+ .svc-legend-item { display:flex; align-items:center; gap:4px; }
1384
+ .svc-legend-dot { width:10px; height:10px; border-radius:50%; }
1385
+
1386
+ .svc-pipeline-container { display:flex; flex-direction:column; gap:24px; padding:16px; }
1387
+ .svc-pipeline { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px; }
1388
+ .svc-pipeline-title { font-size:14px; font-weight:600; color:var(--text); margin-bottom:4px; }
1389
+ .svc-pipeline-desc { font-size:12px; color:var(--muted); margin-bottom:12px; }
1390
+ .svc-pipeline-triggers { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px; }
1391
+ .svc-pipeline-trigger { font-size:10px; padding:2px 8px; border-radius:10px; background:rgba(88,166,255,0.15); color:#58a6ff; }
1392
+ .svc-pipeline-steps { display:flex; align-items:center; gap:0; overflow-x:auto; padding:8px 0; }
1393
+ .svc-step { display:flex; flex-direction:column; align-items:center; min-width:100px; flex-shrink:0; }
1394
+ .svc-step-box { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px 12px; text-align:center; width:100px; cursor:pointer; transition:border-color 0.15s; }
1395
+ .svc-step-box:hover { border-color:var(--accent); }
1396
+ .svc-step-label { font-size:11px; color:var(--text); font-weight:500; }
1397
+ .svc-step-service { font-size:9px; color:var(--muted); margin-top:2px; }
1398
+ .svc-step-arrow { color:var(--muted); font-size:16px; padding:0 4px; flex-shrink:0; }
1399
+
1400
+ .svc-export { padding:16px; }
1401
+ .svc-export-section { margin-bottom:24px; }
1402
+ .svc-export-title { font-size:14px; font-weight:600; color:var(--text); margin-bottom:8px; }
1403
+ .svc-export-table { width:100%; border-collapse:collapse; font-size:12px; }
1404
+ .svc-export-table th { text-align:left; padding:6px 8px; border-bottom:1px solid var(--border); color:var(--muted); font-weight:500; }
1405
+ .svc-export-table td { padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.05); color:var(--text); }
1406
+ .svc-export-btns { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
1407
+ .svc-export-btn { background:var(--surface); border:1px solid var(--border); color:var(--text); padding:8px 16px; border-radius:6px; cursor:pointer; font-size:12px; transition:all 0.15s; }
1408
+ .svc-export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
1409
+ .svc-alert-badge { display:inline-block; padding:1px 6px; border-radius:8px; font-size:10px; font-weight:500; }
1410
+ .svc-alert-badge.critical { background:rgba(248,81,73,0.2); color:#f85149; }
1411
+ .svc-alert-badge.warning { background:rgba(227,179,65,0.2); color:#e3b341; }
1412
+ .svc-alert-badge.info { background:rgba(88,166,255,0.2); color:#58a6ff; }
1413
+
1414
+ .svc-empty { text-align:center; padding:60px 20px; color:var(--muted); }
1415
+ .svc-empty h3 { color:var(--text); margin-bottom:8px; }
1416
+
1417
+ /* Service detail panel */
1418
+ .svc-detail-section { margin-bottom:16px; }
1419
+ .svc-detail-section h4 { font-size:12px; color:var(--muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.5px; }
1420
+ .svc-detail-row { font-size:12px; color:var(--text); padding:3px 0; }
1421
+ .svc-detail-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; }
1422
+
1357
1423
  </style>
1358
1424
  </head>
1359
1425
  <body>
@@ -1501,17 +1567,20 @@ const modeStore = {
1501
1567
  qa: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null },
1502
1568
  observability: { view: 'files', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null },
1503
1569
  docs: { view: 'features', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null },
1570
+ services: { view: 'graph', searchQuery: '', activeTypes: new Set(), drillFeatureKey: null, drillTestType: null, activePanelKey: null, showOnlyUntestedInFeature: false, testNavType: 'all', testNavFeature: null, testNavProblem: null, svcTab: 'graph' },
1504
1571
  };
1505
1572
 
1506
1573
  function getModeFromPath(pathname = window.location.pathname) {
1507
1574
  if (pathname.startsWith('/radar/observability')) return 'observability';
1508
1575
  if (pathname.startsWith('/radar/docs')) return 'docs';
1576
+ if (pathname.startsWith('/radar/services')) return 'services';
1509
1577
  return 'qa';
1510
1578
  }
1511
1579
 
1512
1580
  function routePathForMode(mode) {
1513
1581
  if (mode === 'observability') return '/radar/observability';
1514
1582
  if (mode === 'docs') return '/radar/docs';
1583
+ if (mode === 'services') return '/radar/services';
1515
1584
  return '/radar/qa';
1516
1585
  }
1517
1586
 
@@ -1555,7 +1624,7 @@ function switchMode(nextMode) {
1555
1624
  saveModeState(contextMode);
1556
1625
  contextMode = nextMode;
1557
1626
  restoreModeState(contextMode);
1558
- if (contextMode === 'observability' || contextMode === 'docs') {
1627
+ if (contextMode === 'observability' || contextMode === 'docs' || contextMode === 'services') {
1559
1628
  view = 'features';
1560
1629
  drillFeatureKey = null;
1561
1630
  drillTestType = null;
@@ -2795,6 +2864,25 @@ function renderStats() {
2795
2864
  { v: '—', l: 'Шумных паттернов' },
2796
2865
  ];
2797
2866
  }
2867
+ } else if (contextMode === 'services') {
2868
+ const sm = D.serviceMap;
2869
+ if (sm) {
2870
+ items = [
2871
+ { v: sm.nodes.length, l: 'Сервисов' },
2872
+ { v: sm.edges.length, l: 'Зависимостей' },
2873
+ { v: sm.pipelines.length, l: 'Пайплайнов' },
2874
+ { v: sm.autodiscovery.dockerServices + sm.autodiscovery.envConnections + sm.autodiscovery.npmServices, l: 'Автодискаверинг' },
2875
+ sm.nodes.filter(n => n.alerts && n.alerts.length > 0).length > 0
2876
+ ? { v: sm.nodes.reduce((s, n) => s + (n.alerts?.length || 0), 0), l: 'Алертов' }
2877
+ : null,
2878
+ ].filter(Boolean);
2879
+ } else {
2880
+ items = [
2881
+ { v: 0, l: 'Сервисов' },
2882
+ { v: '—', l: 'Зависимостей' },
2883
+ { v: '—', l: 'Пайплайнов' },
2884
+ ];
2885
+ }
2798
2886
  } else if (D.hasConfig && D.features) {
2799
2887
  const unmapped = src.filter(m => !m.isInfra && (!m.featureKeys || m.featureKeys.length === 0)).length;
2800
2888
  items = [
@@ -2827,6 +2915,7 @@ function renderModeSwitch() {
2827
2915
  { key: 'qa', label: 'QA Coverage', hint: 'Покрытие, пробелы, тренды' },
2828
2916
  { key: 'observability', label: 'Наблюдаемость', hint: 'Логи, шум, сигналы ошибок' },
2829
2917
  { key: 'docs', label: 'Документация', hint: 'Актуальность, генерация, обновление' },
2918
+ { key: 'services', label: 'Карта сервисов', hint: 'Зависимости, пайплайны, мониторинг' },
2830
2919
  ];
2831
2920
  root.innerHTML = modes.map(m => `
2832
2921
  <button class="mode-switch-btn ${contextMode === m.key ? 'active' : ''}" data-mode="${m.key}">
@@ -2865,6 +2954,29 @@ function renderSidebar() {
2865
2954
  return;
2866
2955
  }
2867
2956
 
2957
+ if (contextMode === 'services') {
2958
+ tabs.style.display = 'none';
2959
+ const svcTab = modeStore.services.svcTab || 'graph';
2960
+ extra.innerHTML = `
2961
+ <div class="sidebar-label">Карта сервисов</div>
2962
+ <div style="font-size:12px;color:var(--muted);padding:0 6px;line-height:1.45;margin-bottom:12px">
2963
+ Зависимости, пайплайны, экспорт для мониторинга.
2964
+ </div>
2965
+ <div class="svc-sidebar-tabs">
2966
+ <button class="svc-sidebar-tab ${svcTab === 'graph' ? 'active' : ''}" data-svctab="graph">Граф</button>
2967
+ <button class="svc-sidebar-tab ${svcTab === 'pipelines' ? 'active' : ''}" data-svctab="pipelines">Пайплайны</button>
2968
+ <button class="svc-sidebar-tab ${svcTab === 'export' ? 'active' : ''}" data-svctab="export">Экспорт</button>
2969
+ </div>`;
2970
+ extra.querySelectorAll('.svc-sidebar-tab').forEach(btn => {
2971
+ btn.onclick = () => {
2972
+ modeStore.services.svcTab = btn.dataset.svctab;
2973
+ renderSidebar();
2974
+ renderContent();
2975
+ };
2976
+ });
2977
+ return;
2978
+ }
2979
+
2868
2980
  tabs.style.display = 'flex';
2869
2981
  document.querySelectorAll('.view-tab').forEach(t =>
2870
2982
  t.classList.toggle('active', t.dataset.view === view)
@@ -2914,6 +3026,10 @@ function renderContent() {
2914
3026
  renderDocumentation(c);
2915
3027
  return;
2916
3028
  }
3029
+ if (contextMode === 'services') {
3030
+ renderServiceMap(c);
3031
+ return;
3032
+ }
2917
3033
 
2918
3034
  if (view === 'features') {
2919
3035
  if (drillFeatureKey === '__unmapped__') renderUnmappedDetail(c);
@@ -2926,6 +3042,376 @@ function renderContent() {
2926
3042
  }
2927
3043
  }
2928
3044
 
3045
+ // ─── Service Map rendering ──────────────────────────────────────────────────
3046
+
3047
+ const SVC_CATEGORY_COLORS = {
3048
+ 'database': { fill: '#1a3a2a', stroke: '#3fb950', text: '#3fb950' },
3049
+ 'cache': { fill: '#3a2a1a', stroke: '#ffa657', text: '#ffa657' },
3050
+ 'queue': { fill: '#2a1a3a', stroke: '#d2a8ff', text: '#d2a8ff' },
3051
+ 'storage': { fill: '#1a2a3a', stroke: '#58a6ff', text: '#58a6ff' },
3052
+ 'external-api': { fill: '#3a1a2a', stroke: '#f85149', text: '#f85149' },
3053
+ 'internal-service': { fill: '#1a2a2a', stroke: '#79c0ff', text: '#79c0ff' },
3054
+ 'worker': { fill: '#2a2a1a', stroke: '#e3b341', text: '#e3b341' },
3055
+ 'gateway': { fill: '#2a1a2a', stroke: '#a371f7', text: '#a371f7' },
3056
+ };
3057
+
3058
+ const SVC_GROUP_ORDER = ['gateway', 'external', 'services', 'workers', 'queues', 'cache', 'storage', 'databases'];
3059
+ const SVC_GROUP_LABELS = { gateway: 'Шлюзы', external: 'Внешние API', services: 'Сервисы', workers: 'Воркеры', queues: 'Очереди', cache: 'Кэш', storage: 'Хранилище', databases: 'Базы данных' };
3060
+
3061
+ function renderServiceMap(c) {
3062
+ const sm = D.serviceMap;
3063
+ if (!sm || sm.nodes.length === 0) {
3064
+ c.innerHTML = `<div class="svc-empty">
3065
+ <h3>Карта сервисов пуста</h3>
3066
+ <p>Автодискаверинг не нашёл сервисов. Добавь секцию <code>services</code> в <code>viberadar.config.json</code> или убедись что в проекте есть docker-compose.yml / .env файлы.</p>
3067
+ </div>`;
3068
+ return;
3069
+ }
3070
+ const svcTab = modeStore.services.svcTab || 'graph';
3071
+ if (svcTab === 'graph') renderSvcGraph(c, sm);
3072
+ else if (svcTab === 'pipelines') renderSvcPipelines(c, sm);
3073
+ else if (svcTab === 'export') renderSvcExport(c, sm);
3074
+ }
3075
+
3076
+ function renderSvcGraph(c, sm) {
3077
+ // Group nodes
3078
+ const groups = {};
3079
+ for (const node of sm.nodes) {
3080
+ const g = node.group || 'services';
3081
+ if (!groups[g]) groups[g] = [];
3082
+ groups[g].push(node);
3083
+ }
3084
+
3085
+ const NODE_W = 130, NODE_H = 56, PAD_X = 40, PAD_Y = 24, GROUP_PAD = 50;
3086
+ const orderedGroups = SVC_GROUP_ORDER.filter(g => groups[g]?.length > 0);
3087
+
3088
+ // Calculate positions
3089
+ const positions = {};
3090
+ let maxRowW = 0;
3091
+ for (const g of orderedGroups) {
3092
+ const row = groups[g];
3093
+ maxRowW = Math.max(maxRowW, row.length * (NODE_W + PAD_X));
3094
+ }
3095
+
3096
+ const svgW = Math.max(maxRowW + PAD_X * 2, 600);
3097
+ let curY = GROUP_PAD;
3098
+ const groupYPositions = {};
3099
+
3100
+ for (const g of orderedGroups) {
3101
+ const row = groups[g];
3102
+ groupYPositions[g] = curY;
3103
+ const totalW = row.length * NODE_W + (row.length - 1) * PAD_X;
3104
+ const startX = (svgW - totalW) / 2;
3105
+ row.forEach((node, i) => {
3106
+ positions[node.id] = {
3107
+ x: startX + i * (NODE_W + PAD_X) + NODE_W / 2,
3108
+ y: curY + NODE_H / 2 + 20, // 20px for group label
3109
+ };
3110
+ });
3111
+ curY += NODE_H + PAD_Y + GROUP_PAD;
3112
+ }
3113
+ const svgH = curY + GROUP_PAD;
3114
+
3115
+ // Build SVG
3116
+ let svg = `<svg class="svc-graph-svg" viewBox="0 0 ${svgW} ${svgH}" xmlns="http://www.w3.org/2000/svg">`;
3117
+ svg += `<defs>
3118
+ <marker id="svc-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
3119
+ <path d="M 0 0 L 10 3.5 L 0 7 z" fill="var(--border)"/>
3120
+ </marker>
3121
+ <marker id="svc-arrow-critical" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
3122
+ <path d="M 0 0 L 10 3.5 L 0 7 z" fill="#f85149"/>
3123
+ </marker>
3124
+ <marker id="svc-arrow-hl" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
3125
+ <path d="M 0 0 L 10 3.5 L 0 7 z" fill="var(--accent)"/>
3126
+ </marker>
3127
+ </defs>`;
3128
+
3129
+ // Group labels
3130
+ for (const g of orderedGroups) {
3131
+ const y = groupYPositions[g];
3132
+ svg += `<text x="16" y="${y + 12}" class="svc-group-label">${SVC_GROUP_LABELS[g] || g}</text>`;
3133
+ }
3134
+
3135
+ // Edges
3136
+ for (const edge of sm.edges) {
3137
+ const from = positions[edge.from];
3138
+ const to = positions[edge.to];
3139
+ if (!from || !to) continue;
3140
+ const cls = edge.critical ? 'svc-edge critical' : 'svc-edge';
3141
+ const marker = edge.critical ? 'url(#svc-arrow-critical)' : 'url(#svc-arrow)';
3142
+ // Bezier curve
3143
+ const dy = to.y - from.y;
3144
+ const dx = to.x - from.x;
3145
+ const cp = Math.abs(dy) * 0.4;
3146
+ const path = `M ${from.x} ${from.y + NODE_H/2} C ${from.x} ${from.y + NODE_H/2 + cp}, ${to.x} ${to.y - NODE_H/2 - cp}, ${to.x} ${to.y - NODE_H/2}`;
3147
+ svg += `<path d="${path}" class="${cls}" marker-end="${marker}" data-from="${edge.from}" data-to="${edge.to}"/>`;
3148
+ if (edge.label) {
3149
+ const mx = (from.x + to.x) / 2;
3150
+ const my = (from.y + to.y) / 2;
3151
+ svg += `<text x="${mx}" y="${my}" class="svc-edge-label" text-anchor="middle">${edge.label}</text>`;
3152
+ }
3153
+ }
3154
+
3155
+ // Nodes
3156
+ for (const node of sm.nodes) {
3157
+ const pos = positions[node.id];
3158
+ if (!pos) continue;
3159
+ const colors = SVC_CATEGORY_COLORS[node.category] || SVC_CATEGORY_COLORS['internal-service'];
3160
+ const x = pos.x - NODE_W / 2;
3161
+ const y = pos.y - NODE_H / 2;
3162
+ svg += `<g class="svc-node-group" data-nodeid="${node.id}" transform="translate(${x},${y})">`;
3163
+ svg += `<rect class="svc-node-rect" width="${NODE_W}" height="${NODE_H}" fill="${colors.fill}" stroke="${colors.stroke}"/>`;
3164
+ svg += `<text class="svc-node-icon" x="${NODE_W/2}" y="22" text-anchor="middle">${node.icon || '📦'}</text>`;
3165
+ svg += `<text class="svc-node-label" x="${NODE_W/2}" y="36" text-anchor="middle">${truncateText(node.label, 16)}</text>`;
3166
+ svg += `<text class="svc-node-category" x="${NODE_W/2}" y="48" text-anchor="middle">${node.category}</text>`;
3167
+ svg += `</g>`;
3168
+ }
3169
+
3170
+ svg += `</svg>`;
3171
+
3172
+ // Legend
3173
+ const legendItems = Object.entries(SVC_CATEGORY_COLORS)
3174
+ .filter(([cat]) => sm.nodes.some(n => n.category === cat))
3175
+ .map(([cat, colors]) => `<div class="svc-legend-item"><div class="svc-legend-dot" style="background:${colors.stroke}"></div>${cat}</div>`)
3176
+ .join('');
3177
+
3178
+ c.innerHTML = `<div class="svc-graph-wrap">${svg}<div class="svc-legend">${legendItems}</div></div>`;
3179
+
3180
+ // Interactivity
3181
+ c.querySelectorAll('.svc-node-group').forEach(g => {
3182
+ g.addEventListener('mouseenter', () => {
3183
+ const id = g.dataset.nodeid;
3184
+ c.querySelectorAll('.svc-edge').forEach(e => {
3185
+ if (e.dataset.from === id || e.dataset.to === id) {
3186
+ e.classList.add('highlighted');
3187
+ e.setAttribute('marker-end', 'url(#svc-arrow-hl)');
3188
+ }
3189
+ });
3190
+ });
3191
+ g.addEventListener('mouseleave', () => {
3192
+ c.querySelectorAll('.svc-edge').forEach(e => {
3193
+ e.classList.remove('highlighted');
3194
+ const isCrit = e.classList.contains('critical');
3195
+ e.setAttribute('marker-end', isCrit ? 'url(#svc-arrow-critical)' : 'url(#svc-arrow)');
3196
+ });
3197
+ });
3198
+ g.addEventListener('click', () => {
3199
+ const id = g.dataset.nodeid;
3200
+ openSvcPanel(id);
3201
+ });
3202
+ });
3203
+ }
3204
+
3205
+ function truncateText(text, maxLen) {
3206
+ return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
3207
+ }
3208
+
3209
+ function renderSvcPipelines(c, sm) {
3210
+ if (!sm.pipelines || sm.pipelines.length === 0) {
3211
+ c.innerHTML = `<div class="svc-empty">
3212
+ <h3>Пайплайны не заданы</h3>
3213
+ <p>Добавь секцию <code>pipelines</code> в <code>services</code> конфиг, чтобы визуализировать потоки данных.</p>
3214
+ </div>`;
3215
+ return;
3216
+ }
3217
+
3218
+ c.innerHTML = `<div class="svc-pipeline-container">${sm.pipelines.map(p => {
3219
+ const triggers = (p.triggers || []).map(t => `<span class="svc-pipeline-trigger">${t}</span>`).join('');
3220
+ const steps = p.steps.map((s, i) => {
3221
+ const arrow = i < p.steps.length - 1 ? '<span class="svc-step-arrow">→</span>' : '';
3222
+ const svcNode = s.serviceId ? sm.nodes.find(n => n.id === s.serviceId) : null;
3223
+ const svcLabel = svcNode ? `${svcNode.icon || ''} ${svcNode.label}` : (s.serviceId || '');
3224
+ return `<div class="svc-step">
3225
+ <div class="svc-step-box" ${s.serviceId ? `data-svcid="${s.serviceId}"` : ''}>
3226
+ <div class="svc-step-label">${s.label}</div>
3227
+ ${svcLabel ? `<div class="svc-step-service">${svcLabel}</div>` : ''}
3228
+ </div>
3229
+ </div>${arrow}`;
3230
+ }).join('');
3231
+ return `<div class="svc-pipeline">
3232
+ <div class="svc-pipeline-title">${p.label}</div>
3233
+ ${p.description ? `<div class="svc-pipeline-desc">${p.description}</div>` : ''}
3234
+ ${triggers ? `<div class="svc-pipeline-triggers">${triggers}</div>` : ''}
3235
+ <div class="svc-pipeline-steps">${steps}</div>
3236
+ </div>`;
3237
+ }).join('')}</div>`;
3238
+
3239
+ // Click on step to open service detail
3240
+ c.querySelectorAll('.svc-step-box[data-svcid]').forEach(box => {
3241
+ box.onclick = () => openSvcPanel(box.dataset.svcid);
3242
+ });
3243
+ }
3244
+
3245
+ function renderSvcExport(c, sm) {
3246
+ // Services table
3247
+ let servicesTable = `<table class="svc-export-table">
3248
+ <thead><tr><th>Сервис</th><th>Категория</th><th>Host</th><th>Port</th><th>Источник</th><th>Health Check</th></tr></thead>
3249
+ <tbody>${sm.nodes.map(n => `<tr>
3250
+ <td>${n.icon || ''} ${n.label}</td>
3251
+ <td>${n.category}</td>
3252
+ <td>${n.host || '—'}</td>
3253
+ <td>${n.port || '—'}</td>
3254
+ <td>${n.source}</td>
3255
+ <td>${n.healthCheck ? `${n.healthCheck.type}: ${n.healthCheck.target}` : '—'}</td>
3256
+ </tr>`).join('')}</tbody>
3257
+ </table>`;
3258
+
3259
+ // Dependencies table
3260
+ let depsTable = '';
3261
+ if (sm.edges.length > 0) {
3262
+ depsTable = `<div class="svc-export-section">
3263
+ <div class="svc-export-title">Зависимости</div>
3264
+ <table class="svc-export-table">
3265
+ <thead><tr><th>From</th><th>To</th><th>Тип</th><th>Описание</th><th>Critical</th></tr></thead>
3266
+ <tbody>${sm.edges.map(e => `<tr>
3267
+ <td>${e.from}</td><td>${e.to}</td><td>${e.type}</td><td>${e.label || '—'}</td>
3268
+ <td>${e.critical ? '<span style="color:#f85149">●</span> Да' : '—'}</td>
3269
+ </tr>`).join('')}</tbody>
3270
+ </table>
3271
+ </div>`;
3272
+ }
3273
+
3274
+ // Alerts table
3275
+ let alertsTable = '';
3276
+ const allAlerts = sm.nodes.flatMap(n => (n.alerts || []).map(a => ({ ...a, service: n.label, serviceId: n.id })));
3277
+ if (allAlerts.length > 0) {
3278
+ alertsTable = `<div class="svc-export-section">
3279
+ <div class="svc-export-title">Рекомендации по алертам</div>
3280
+ <table class="svc-export-table">
3281
+ <thead><tr><th>Сервис</th><th>Метрика</th><th>Severity</th><th>Описание</th></tr></thead>
3282
+ <tbody>${allAlerts.map(a => `<tr>
3283
+ <td>${a.service}</td><td><code>${a.metric}</code></td>
3284
+ <td><span class="svc-alert-badge ${a.severity}">${a.severity}</span></td>
3285
+ <td>${a.description}</td>
3286
+ </tr>`).join('')}</tbody>
3287
+ </table>
3288
+ </div>`;
3289
+ }
3290
+
3291
+ c.innerHTML = `<div class="svc-export">
3292
+ <div class="svc-export-section">
3293
+ <div class="svc-export-title">Сервисы</div>
3294
+ ${servicesTable}
3295
+ </div>
3296
+ ${depsTable}
3297
+ ${alertsTable}
3298
+ <div class="svc-export-btns">
3299
+ <button class="svc-export-btn" id="svcCopyJson">📋 Copy JSON</button>
3300
+ <button class="svc-export-btn" id="svcCopyMd">📄 Copy Markdown</button>
3301
+ <button class="svc-export-btn" id="svcCopyProm">⚡ Copy Prometheus Rules</button>
3302
+ </div>
3303
+ </div>`;
3304
+
3305
+ document.getElementById('svcCopyJson').onclick = async () => {
3306
+ try {
3307
+ const res = await fetch('/api/service-map?format=json');
3308
+ const text = await res.text();
3309
+ await navigator.clipboard.writeText(text);
3310
+ showToast('JSON скопирован в буфер обмена');
3311
+ } catch (e) { showToast('Ошибка копирования'); }
3312
+ };
3313
+ document.getElementById('svcCopyMd').onclick = async () => {
3314
+ try {
3315
+ const res = await fetch('/api/service-map?format=markdown');
3316
+ const text = await res.text();
3317
+ await navigator.clipboard.writeText(text);
3318
+ showToast('Markdown скопирован в буфер обмена');
3319
+ } catch (e) { showToast('Ошибка копирования'); }
3320
+ };
3321
+ document.getElementById('svcCopyProm').onclick = async () => {
3322
+ try {
3323
+ const res = await fetch('/api/service-map?format=prometheus');
3324
+ const text = await res.text();
3325
+ await navigator.clipboard.writeText(text);
3326
+ showToast('Prometheus rules скопированы в буфер обмена');
3327
+ } catch (e) { showToast('Ошибка копирования'); }
3328
+ };
3329
+ }
3330
+
3331
+ function showToast(msg) {
3332
+ let toast = document.getElementById('svc-toast');
3333
+ if (!toast) {
3334
+ toast = document.createElement('div');
3335
+ toast.id = 'svc-toast';
3336
+ toast.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--surface);color:var(--text);padding:8px 20px;border-radius:6px;border:1px solid var(--border);font-size:12px;z-index:9999;opacity:0;transition:opacity 0.3s;';
3337
+ document.body.appendChild(toast);
3338
+ }
3339
+ toast.textContent = msg;
3340
+ toast.style.opacity = '1';
3341
+ setTimeout(() => { toast.style.opacity = '0'; }, 2000);
3342
+ }
3343
+
3344
+ function openSvcPanel(nodeId) {
3345
+ const sm = D.serviceMap;
3346
+ if (!sm) return;
3347
+ const node = sm.nodes.find(n => n.id === nodeId);
3348
+ if (!node) return;
3349
+
3350
+ const panel = document.getElementById('panel');
3351
+ const colors = SVC_CATEGORY_COLORS[node.category] || SVC_CATEGORY_COLORS['internal-service'];
3352
+
3353
+ // Incoming/outgoing edges
3354
+ const incoming = sm.edges.filter(e => e.to === nodeId);
3355
+ const outgoing = sm.edges.filter(e => e.from === nodeId);
3356
+
3357
+ // Pipelines this node participates in
3358
+ const pipelines = sm.pipelines.filter(p => p.steps.some(s => s.serviceId === nodeId));
3359
+
3360
+ let html = `<div style="padding:16px">
3361
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
3362
+ <span style="font-size:28px">${node.icon || '📦'}</span>
3363
+ <div>
3364
+ <div style="font-size:16px;font-weight:600;color:var(--text)">${node.label}</div>
3365
+ <div style="font-size:11px;color:${colors.text}">${node.category}</div>
3366
+ </div>
3367
+ </div>
3368
+
3369
+ <div class="svc-detail-section">
3370
+ <h4>Информация</h4>
3371
+ <div class="svc-detail-row">ID: <code>${node.id}</code></div>
3372
+ <div class="svc-detail-row">Источник: <span class="svc-detail-badge" style="background:${node.source === 'config' ? 'rgba(88,166,255,0.15)' : 'rgba(63,185,80,0.15)'};color:${node.source === 'config' ? '#58a6ff' : '#3fb950'}">${node.source}</span></div>
3373
+ ${node.host ? `<div class="svc-detail-row">Host: <code>${node.host}</code></div>` : ''}
3374
+ ${node.port ? `<div class="svc-detail-row">Port: <code>${node.port}</code></div>` : ''}
3375
+ ${node.group ? `<div class="svc-detail-row">Группа: ${node.group}</div>` : ''}
3376
+ </div>`;
3377
+
3378
+ if (incoming.length > 0) {
3379
+ html += `<div class="svc-detail-section"><h4>Входящие зависимости (${incoming.length})</h4>
3380
+ ${incoming.map(e => `<div class="svc-detail-row">${e.from} → <strong>${nodeId}</strong> ${e.label ? `(${e.label})` : ''} <span style="color:var(--muted)">${e.type}</span>${e.critical ? ' <span style="color:#f85149">critical</span>' : ''}</div>`).join('')}
3381
+ </div>`;
3382
+ }
3383
+ if (outgoing.length > 0) {
3384
+ html += `<div class="svc-detail-section"><h4>Исходящие зависимости (${outgoing.length})</h4>
3385
+ ${outgoing.map(e => `<div class="svc-detail-row"><strong>${nodeId}</strong> → ${e.to} ${e.label ? `(${e.label})` : ''} <span style="color:var(--muted)">${e.type}</span>${e.critical ? ' <span style="color:#f85149">critical</span>' : ''}</div>`).join('')}
3386
+ </div>`;
3387
+ }
3388
+ if (pipelines.length > 0) {
3389
+ html += `<div class="svc-detail-section"><h4>Участвует в пайплайнах (${pipelines.length})</h4>
3390
+ ${pipelines.map(p => {
3391
+ const stepIdx = p.steps.findIndex(s => s.serviceId === nodeId);
3392
+ return `<div class="svc-detail-row">📊 ${p.label} — шаг ${stepIdx + 1}: ${p.steps[stepIdx].label}</div>`;
3393
+ }).join('')}
3394
+ </div>`;
3395
+ }
3396
+ if (node.healthCheck) {
3397
+ html += `<div class="svc-detail-section"><h4>Health Check</h4>
3398
+ <div class="svc-detail-row">Тип: ${node.healthCheck.type}</div>
3399
+ <div class="svc-detail-row">Target: <code>${node.healthCheck.target}</code></div>
3400
+ ${node.healthCheck.interval ? `<div class="svc-detail-row">Интервал: ${node.healthCheck.interval}</div>` : ''}
3401
+ </div>`;
3402
+ }
3403
+ if (node.alerts && node.alerts.length > 0) {
3404
+ html += `<div class="svc-detail-section"><h4>Алерты (${node.alerts.length})</h4>
3405
+ ${node.alerts.map(a => `<div class="svc-detail-row"><span class="svc-alert-badge ${a.severity}">${a.severity}</span> <code>${a.metric}</code> — ${a.description}</div>`).join('')}
3406
+ </div>`;
3407
+ }
3408
+
3409
+ html += `</div>`;
3410
+
3411
+ document.getElementById('panelContent').innerHTML = html;
3412
+ panel.classList.add('open');
3413
+ }
3414
+
2929
3415
  function renderQaOnboarding() {
2930
3416
  return `<div class="onboarding-block"><h3>QA Coverage: что это?</h3><p>Этот экран помогает найти пробелы в тестах: сначала проверь покрытие по фичам, затем открой критичные непокрытые файлы и запусти генерацию/фиксы тестов.</p></div>`;
2931
3417
  }
@@ -4948,6 +5434,14 @@ function renderTestNavSidebar(extra) {
4948
5434
  <span class="tn-filter-count ${f.cls || ''}">${f.count}</span>
4949
5435
  </div>
4950
5436
  `).join('')}
5437
+ ${D.agent && problemCounts['no-source'] > 0 ? `
5438
+ <button id="tnLinkOrphanBtn" style="margin:8px 0 2px;padding:5px 10px;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;color:var(--accent);font-size:11px;cursor:pointer;width:100%;text-align:left;">
5439
+ ▶ Связать с исходниками (${problemCounts['no-source']})
5440
+ </button>` : ''}
5441
+ ${D.agent && problemCounts['no-feature'] > 0 ? `
5442
+ <button id="tnClassifyOrphanBtn" style="margin:2px 0 0;padding:5px 10px;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;color:var(--accent);font-size:11px;cursor:pointer;width:100%;text-align:left;">
5443
+ ▶ Привязать к фичам (${problemCounts['no-feature']})
5444
+ </button>` : ''}
4951
5445
  </div>
4952
5446
  <div class="tn-sidebar-section">
4953
5447
  <div class="tn-sidebar-label">Фича</div>
@@ -4987,6 +5481,10 @@ function renderTestNavSidebar(extra) {
4987
5481
  renderSidebar(); renderContent();
4988
5482
  };
4989
5483
  });
5484
+ const linkBtn = document.getElementById('tnLinkOrphanBtn');
5485
+ if (linkBtn) linkBtn.onclick = () => runAgentTask('link-orphan-tests', null, null, null, { batch: 0 });
5486
+ const classifyBtn = document.getElementById('tnClassifyOrphanBtn');
5487
+ if (classifyBtn) classifyBtn.onclick = () => runAgentTask('classify-orphan-tests', null, null, null, { batch: 0 });
4990
5488
  }
4991
5489
 
4992
5490
  function renderTestNavigator(c) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberadar",
3
- "version": "0.3.113",
3
+ "version": "0.3.115",
4
4
  "description": "Live module map with test coverage for vibecoding projects",
5
5
  "main": "./dist/cli.js",
6
6
  "bin": {
@@ -30,14 +30,16 @@
30
30
  "viberadar.js"
31
31
  ],
32
32
  "dependencies": {
33
- "open": "^10.1.0",
34
33
  "chokidar": "^3.6.0",
35
- "glob": "^11.0.0"
34
+ "glob": "^11.0.0",
35
+ "js-yaml": "^4.1.1",
36
+ "open": "^10.1.0"
36
37
  },
37
38
  "devDependencies": {
39
+ "@types/js-yaml": "^4.0.9",
38
40
  "@types/node": "^22.0.0",
39
- "typescript": "^5.7.0",
40
- "ts-node": "^10.9.2"
41
+ "ts-node": "^10.9.2",
42
+ "typescript": "^5.7.0"
41
43
  },
42
44
  "engines": {
43
45
  "node": ">=18"