@cccarv82/freya 2.13.3 → 2.14.0
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/cli/web-ui.css +98 -0
- package/cli/web-ui.js +182 -19
- package/cli/web.js +234 -81
- package/package.json +1 -1
package/cli/web-ui.css
CHANGED
|
@@ -371,6 +371,7 @@ body {
|
|
|
371
371
|
align-items: center;
|
|
372
372
|
padding: 16px 20px 10px;
|
|
373
373
|
background: transparent;
|
|
374
|
+
flex-shrink: 0;
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
.brandLine {
|
|
@@ -592,6 +593,9 @@ body {
|
|
|
592
593
|
gap: 18px;
|
|
593
594
|
flex: 1;
|
|
594
595
|
}
|
|
596
|
+
.centerBody > * {
|
|
597
|
+
flex-shrink: 0;
|
|
598
|
+
}
|
|
595
599
|
|
|
596
600
|
.promptShell {
|
|
597
601
|
display: flex;
|
|
@@ -1497,6 +1501,100 @@ textarea:focus {
|
|
|
1497
1501
|
animation: fadeIn 0.2s ease-out;
|
|
1498
1502
|
}
|
|
1499
1503
|
|
|
1504
|
+
/* ── Companion Dashboard Tabs ── */
|
|
1505
|
+
.companionTabs {
|
|
1506
|
+
display: flex;
|
|
1507
|
+
gap: 8px;
|
|
1508
|
+
margin: 16px 0;
|
|
1509
|
+
border-bottom: 1px solid var(--border);
|
|
1510
|
+
padding-bottom: 8px;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
.companionTabs .tab {
|
|
1514
|
+
padding: 8px 12px;
|
|
1515
|
+
background: transparent;
|
|
1516
|
+
border: none;
|
|
1517
|
+
cursor: pointer;
|
|
1518
|
+
color: var(--text);
|
|
1519
|
+
font-size: 13px;
|
|
1520
|
+
position: relative;
|
|
1521
|
+
transition: color 0.2s ease;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
.companionTabs .tab:hover {
|
|
1525
|
+
color: var(--accent);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
.companionTabs .tab.active {
|
|
1529
|
+
color: var(--accent);
|
|
1530
|
+
border-bottom: 2px solid var(--accent);
|
|
1531
|
+
padding-bottom: 6px;
|
|
1532
|
+
margin-bottom: -10px;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/* ── Consolidated View (4 KPI Cards) ── */
|
|
1536
|
+
.consolidatedView {
|
|
1537
|
+
display: grid;
|
|
1538
|
+
grid-template-columns: repeat(4, 1fr);
|
|
1539
|
+
gap: 16px;
|
|
1540
|
+
margin-bottom: 24px;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
.kpiCard {
|
|
1544
|
+
background: var(--paper);
|
|
1545
|
+
padding: 16px;
|
|
1546
|
+
border: 1px solid var(--border);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/* ── Project Cards Grid ── */
|
|
1550
|
+
.projectCardsGrid {
|
|
1551
|
+
display: grid;
|
|
1552
|
+
grid-template-columns: repeat(2, 1fr);
|
|
1553
|
+
gap: 16px;
|
|
1554
|
+
margin-bottom: 24px;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
.projectCard {
|
|
1558
|
+
background: var(--paper);
|
|
1559
|
+
padding: 16px;
|
|
1560
|
+
border-left: 3px solid #4ade80;
|
|
1561
|
+
cursor: pointer;
|
|
1562
|
+
transition: box-shadow 0.2s ease;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
.projectCard:hover {
|
|
1566
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
.projectCard.at_risk {
|
|
1570
|
+
border-left-color: #ff9900;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
.projectCard.idle {
|
|
1574
|
+
border-left-color: #666;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/* ── Stream Item ── */
|
|
1578
|
+
.streamItem {
|
|
1579
|
+
padding: 8px;
|
|
1580
|
+
margin: 4px 0;
|
|
1581
|
+
border: 1px solid var(--border);
|
|
1582
|
+
cursor: pointer;
|
|
1583
|
+
transition: background 0.2s ease;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
.streamItem:hover {
|
|
1587
|
+
background: var(--bg);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/* ── Alert Item ── */
|
|
1591
|
+
.alertItem {
|
|
1592
|
+
padding: 12px;
|
|
1593
|
+
margin: 8px 0;
|
|
1594
|
+
border-left: 3px solid #ef4444;
|
|
1595
|
+
background: rgba(239, 68, 68, 0.05);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1500
1598
|
* {
|
|
1501
1599
|
border-radius: 0 !important;
|
|
1502
1600
|
}
|
package/cli/web-ui.js
CHANGED
|
@@ -139,9 +139,10 @@
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
const li = line.match(/^[ \t]*[-*][ \t]+(.*)$/);
|
|
142
|
-
|
|
142
|
+
const oli = !li ? line.match(/^[ \t]*\d+\.[ \t]+(.*)$/) : null;
|
|
143
|
+
if (li || oli) {
|
|
143
144
|
if (!inList) { html += '<ul class="md-ul">'; inList = true; }
|
|
144
|
-
const content = inlineFormat(li[1]);
|
|
145
|
+
const content = inlineFormat((li || oli)[1]);
|
|
145
146
|
html += '<li>' + content + '</li>';
|
|
146
147
|
continue;
|
|
147
148
|
}
|
|
@@ -192,42 +193,37 @@
|
|
|
192
193
|
|
|
193
194
|
// --- Strategy 2: regex fallback for truncated/malformed JSON ---
|
|
194
195
|
var lines = [];
|
|
195
|
-
var num = 0;
|
|
196
196
|
|
|
197
197
|
// Match append_daily_log / appenddailylog actions
|
|
198
198
|
var logRe = /"type"\s*:\s*"append_?daily_?log"\s*,\s*"text"\s*:\s*"([^"]{1,300})/gi;
|
|
199
199
|
var m;
|
|
200
200
|
while ((m = logRe.exec(text)) !== null) {
|
|
201
|
-
num++;
|
|
202
201
|
var t = m[1].slice(0, 140);
|
|
203
|
-
lines.push(
|
|
202
|
+
lines.push('- \u{1F4DD} **Registrar no log:** ' + t + (m[1].length > 140 ? '...' : ''));
|
|
204
203
|
}
|
|
205
204
|
|
|
206
205
|
// Match create_task actions
|
|
207
206
|
var taskRe = /"type"\s*:\s*"create_?task"\s*,\s*"description"\s*:\s*"([^"]{1,200})/gi;
|
|
208
207
|
while ((m = taskRe.exec(text)) !== null) {
|
|
209
|
-
num++;
|
|
210
208
|
var desc = m[1].slice(0, 120);
|
|
211
209
|
var priMatch = text.slice(m.index, m.index + 400).match(/"priority"\s*:\s*"(\w+)"/i);
|
|
212
210
|
var pri = priMatch ? ' (prioridade: **' + priMatch[1].toUpperCase() + '**)' : '';
|
|
213
|
-
lines.push(
|
|
211
|
+
lines.push('- \u2705 **Criar tarefa:** ' + desc + pri);
|
|
214
212
|
}
|
|
215
213
|
|
|
216
214
|
// Match create_blocker actions
|
|
217
215
|
var blockerRe = /"type"\s*:\s*"create_?blocker"\s*,\s*"title"\s*:\s*"([^"]{1,200})/gi;
|
|
218
216
|
while ((m = blockerRe.exec(text)) !== null) {
|
|
219
|
-
num++;
|
|
220
217
|
var title = m[1].slice(0, 120);
|
|
221
218
|
var sevMatch = text.slice(m.index, m.index + 400).match(/"severity"\s*:\s*"(\w+)"/i);
|
|
222
219
|
var sev = sevMatch ? ' (severidade: **' + sevMatch[1].toUpperCase() + '**)' : '';
|
|
223
|
-
lines.push(
|
|
220
|
+
lines.push('- \u{1F6A7} **Registrar blocker:** ' + title + sev);
|
|
224
221
|
}
|
|
225
222
|
|
|
226
223
|
// Match suggest_report actions
|
|
227
224
|
var repRe = /"type"\s*:\s*"suggest_?report"\s*,\s*"name"\s*:\s*"([^"]+)"/gi;
|
|
228
225
|
while ((m = repRe.exec(text)) !== null) {
|
|
229
|
-
|
|
230
|
-
lines.push(num + '. \u{1F4CA} **Sugerir relatorio:** ' + m[1]);
|
|
226
|
+
lines.push('- \u{1F4CA} **Sugerir relatorio:** ' + m[1]);
|
|
231
227
|
}
|
|
232
228
|
|
|
233
229
|
return lines.length > 0 ? lines.join('\n') : null;
|
|
@@ -240,33 +236,32 @@
|
|
|
240
236
|
oraclequery: '\u{1F50D}'
|
|
241
237
|
};
|
|
242
238
|
|
|
243
|
-
var lines = actions.map(function(a
|
|
239
|
+
var lines = actions.map(function(a) {
|
|
244
240
|
var type = String(a.type || '').trim().toLowerCase().replace(/_/g, '');
|
|
245
241
|
var icon = icons[type] || '\u2022';
|
|
246
|
-
var num = i + 1;
|
|
247
242
|
|
|
248
243
|
if (type === 'appenddailylog') {
|
|
249
244
|
var t = String(a.text || '').slice(0, 140);
|
|
250
|
-
return
|
|
245
|
+
return '- ' + icon + ' **Registrar no log:** ' + t + (String(a.text || '').length > 140 ? '...' : '');
|
|
251
246
|
}
|
|
252
247
|
if (type === 'createtask') {
|
|
253
248
|
var desc = String(a.description || '').slice(0, 120);
|
|
254
249
|
var pri = a.priority ? ' (prioridade: **' + String(a.priority).toUpperCase() + '**)' : '';
|
|
255
250
|
var cat = a.category ? ' [' + a.category + ']' : '';
|
|
256
|
-
return
|
|
251
|
+
return '- ' + icon + ' **Criar tarefa:** ' + desc + pri + cat;
|
|
257
252
|
}
|
|
258
253
|
if (type === 'createblocker') {
|
|
259
254
|
var title = String(a.title || a.description || '').slice(0, 120);
|
|
260
255
|
var sev = a.severity ? ' (severidade: **' + String(a.severity).toUpperCase() + '**)' : '';
|
|
261
|
-
return
|
|
256
|
+
return '- ' + icon + ' **Registrar blocker:** ' + title + sev;
|
|
262
257
|
}
|
|
263
258
|
if (type === 'suggestreport') {
|
|
264
|
-
return
|
|
259
|
+
return '- ' + icon + ' **Sugerir relatorio:** ' + String(a.name || a.reportType || '');
|
|
265
260
|
}
|
|
266
261
|
if (type === 'oraclequery') {
|
|
267
|
-
return
|
|
262
|
+
return '- ' + icon + ' **Consultar oracle:** ' + String(a.query || '').slice(0, 120);
|
|
268
263
|
}
|
|
269
|
-
return
|
|
264
|
+
return '- \u2022 **' + String(a.type || 'acao') + '**';
|
|
270
265
|
});
|
|
271
266
|
|
|
272
267
|
return lines.join('\n');
|
|
@@ -2135,6 +2130,172 @@
|
|
|
2135
2130
|
}
|
|
2136
2131
|
}
|
|
2137
2132
|
|
|
2133
|
+
async function refreshCompanionDash() {
|
|
2134
|
+
try {
|
|
2135
|
+
setPill('run', 'carregando dashboard...');
|
|
2136
|
+
const [pRes, sRes, aRes] = await Promise.all([
|
|
2137
|
+
api('/api/companion/projects-summary', { dir: dirOrDefault() }),
|
|
2138
|
+
api('/api/companion/streams-breakdown', { dir: dirOrDefault() }),
|
|
2139
|
+
api('/api/companion/alerts', { dir: dirOrDefault() })
|
|
2140
|
+
]);
|
|
2141
|
+
if (!pRes || !pRes.ok || !sRes || !sRes.ok || !aRes || !aRes.ok) {
|
|
2142
|
+
setPill('err', 'Falha ao carregar dashboard');
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
renderConsolidatedView(pRes.projects || []);
|
|
2146
|
+
renderProjectCards(pRes.projects || []);
|
|
2147
|
+
renderStreamBreakdown(sRes.items || []);
|
|
2148
|
+
renderAlerts(aRes.alerts || []);
|
|
2149
|
+
setPill('ok', 'dashboard pronto');
|
|
2150
|
+
} catch (e) {
|
|
2151
|
+
setPill('err', 'Erro ao carregar dashboard');
|
|
2152
|
+
console.error('refreshCompanionDash error:', e);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function renderConsolidatedView(projects) {
|
|
2157
|
+
const consolidated = $('companionConsolidated');
|
|
2158
|
+
if (!consolidated) return;
|
|
2159
|
+
let totalCompleted = 0, totalPending = 0, totalCritical = 0;
|
|
2160
|
+
for (const p of projects) {
|
|
2161
|
+
totalCompleted += p.completedTasks || 0;
|
|
2162
|
+
totalPending += p.pendingTasks || 0;
|
|
2163
|
+
totalCritical += (p.blockersBySeverity && p.blockersBySeverity.CRITICAL) || 0;
|
|
2164
|
+
}
|
|
2165
|
+
const avgVelocity = projects.length > 0 ? (projects.reduce((s, p) => s + (p.velocityThisWeek || 0), 0) / projects.length).toFixed(1) : '0';
|
|
2166
|
+
const staleProjects = projects.filter(p => p.status === 'IDLE').length;
|
|
2167
|
+
consolidated.innerHTML = `
|
|
2168
|
+
<div class="kpiCard">
|
|
2169
|
+
<div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">Concluídas</div>
|
|
2170
|
+
<div style="font-size:20px; font-weight:700;">✅ ${totalCompleted}</div>
|
|
2171
|
+
</div>
|
|
2172
|
+
<div class="kpiCard">
|
|
2173
|
+
<div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">Pendentes</div>
|
|
2174
|
+
<div style="font-size:20px; font-weight:700;">⏳ ${totalPending}</div>
|
|
2175
|
+
</div>
|
|
2176
|
+
<div class="kpiCard">
|
|
2177
|
+
<div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">CRITICAL</div>
|
|
2178
|
+
<div style="font-size:20px; font-weight:700; color:#ef4444;">🚧 ${totalCritical}</div>
|
|
2179
|
+
</div>
|
|
2180
|
+
<div class="kpiCard">
|
|
2181
|
+
<div style="font-size:11px; color:var(--textSecondary); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px;">Velocity</div>
|
|
2182
|
+
<div style="font-size:20px; font-weight:700;">📈 ${avgVelocity}/dia</div>
|
|
2183
|
+
</div>
|
|
2184
|
+
`;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
function renderProjectCards(projects) {
|
|
2188
|
+
const grid = $('companionProjects');
|
|
2189
|
+
if (!grid) return;
|
|
2190
|
+
const cards = projects.map(p => {
|
|
2191
|
+
const progressPct = p.totalTasks > 0 ? Math.round((p.completedTasks / p.totalTasks) * 100) : 0;
|
|
2192
|
+
const statusColor = p.status === 'ON_TRACK' ? '#10b981' : p.status === 'AT_RISK' ? '#ff9900' : '#666';
|
|
2193
|
+
const statusLabel = p.status === 'ON_TRACK' ? 'Em dia' : p.status === 'AT_RISK' ? 'Em risco' : 'Inativo';
|
|
2194
|
+
return `
|
|
2195
|
+
<div class="projectCard" style="border-left-color:${statusColor};">
|
|
2196
|
+
<div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:10px;">
|
|
2197
|
+
<div>
|
|
2198
|
+
<div style="font-weight:700; font-size:14px;">${escapeHtml(p.name)}</div>
|
|
2199
|
+
<div style="font-size:11px; color:var(--textSecondary); margin-top:2px;">${statusLabel}</div>
|
|
2200
|
+
</div>
|
|
2201
|
+
<span class="pill" style="background:${statusColor}22; border-color:${statusColor}55; color:${statusColor};">${progressPct}%</span>
|
|
2202
|
+
</div>
|
|
2203
|
+
<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:10px; padding:8px; background:var(--bg); border-radius:0;">
|
|
2204
|
+
<div style="text-align:center;">
|
|
2205
|
+
<div style="font-size:10px; color:var(--textSecondary);">Concluídas</div>
|
|
2206
|
+
<div style="font-size:14px; font-weight:700;">✓ ${p.completedTasks}/${p.totalTasks}</div>
|
|
2207
|
+
</div>
|
|
2208
|
+
<div style="text-align:center;">
|
|
2209
|
+
<div style="font-size:10px; color:var(--textSecondary);">Blockers</div>
|
|
2210
|
+
<div style="font-size:14px; font-weight:700;">🚧 ${p.blockersCount}</div>
|
|
2211
|
+
</div>
|
|
2212
|
+
</div>
|
|
2213
|
+
<div style="font-size:10px; color:var(--textSecondary);">
|
|
2214
|
+
<div>📈 Velocity: ${p.velocityThisWeek.toFixed(1)}/dia</div>
|
|
2215
|
+
<div style="margin-top:2px;">⏱️ Última: ${escapeHtml(p.lastUpdateAgo)}</div>
|
|
2216
|
+
</div>
|
|
2217
|
+
</div>
|
|
2218
|
+
`;
|
|
2219
|
+
}).join('');
|
|
2220
|
+
grid.innerHTML = cards || '<div class="help">Nenhum projeto cadastrado.</div>';
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function renderStreamBreakdown(data) {
|
|
2224
|
+
const el = $('streamBreakdown');
|
|
2225
|
+
if (!el) return;
|
|
2226
|
+
let html = '';
|
|
2227
|
+
for (const proj of data) {
|
|
2228
|
+
html += `<div style="margin-bottom:16px;">
|
|
2229
|
+
<div style="font-weight:700; margin-bottom:8px; color:var(--accent);">${escapeHtml(proj.projectName)}</div>`;
|
|
2230
|
+
for (const stream of proj.streams) {
|
|
2231
|
+
const pct = stream.totalTasks > 0 ? Math.round((stream.completedTasks / stream.totalTasks) * 100) : 0;
|
|
2232
|
+
const blockersLabel = stream.blockersCount > 0 ? `🚧 ${stream.blockersCount} ${stream.blockersHighestSeverity || ''}` : '✓';
|
|
2233
|
+
html += `<div class="streamItem">
|
|
2234
|
+
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
2235
|
+
<div>
|
|
2236
|
+
<div style="font-size:12px;">${escapeHtml(stream.streamName)}</div>
|
|
2237
|
+
<div style="font-size:10px; color:var(--textSecondary); margin-top:2px;">✓ ${stream.completedTasks}/${stream.totalTasks} (${pct}%) | ${blockersLabel}</div>
|
|
2238
|
+
</div>
|
|
2239
|
+
</div>
|
|
2240
|
+
</div>`;
|
|
2241
|
+
}
|
|
2242
|
+
html += `</div>`;
|
|
2243
|
+
}
|
|
2244
|
+
el.innerHTML = html || '<div class="help">Nenhuma frente cadastrada.</div>';
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
function renderAlerts(alerts) {
|
|
2248
|
+
const alertsZone = $('companionAlerts');
|
|
2249
|
+
const alertsList = $('alertsList');
|
|
2250
|
+
if (!alertsList) return;
|
|
2251
|
+
if (!alerts || alerts.length === 0) {
|
|
2252
|
+
alertsList.innerHTML = '<div class="help">✓ Nenhum alerta no momento!</div>';
|
|
2253
|
+
if (alertsZone) alertsZone.style.display = 'none';
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
const items = alerts.map(a => {
|
|
2257
|
+
const sevColor = a.severity === 'CRITICAL' ? '#ef4444' : a.severity === 'HIGH' ? '#ff9900' : '#eab308';
|
|
2258
|
+
const sevLabel = a.severity === 'CRITICAL' ? '🔴 CRÍTICO' : a.severity === 'HIGH' ? '🟠 ALTO' : '🟡 MÉDIO';
|
|
2259
|
+
return `<div class="alertItem" style="border-left-color:${sevColor}; background:${sevColor}11;">
|
|
2260
|
+
<div style="display:flex; justify-content:space-between; gap:10px; align-items:start;">
|
|
2261
|
+
<div style="min-width:0; flex:1;">
|
|
2262
|
+
<div style="font-weight:700; color:${sevColor};">${sevLabel}</div>
|
|
2263
|
+
<div style="font-size:12px; margin-top:4px;">${escapeHtml(a.message)}</div>
|
|
2264
|
+
${a.projectSlug ? `<div style="font-size:10px; color:var(--textSecondary); margin-top:4px;">Projeto: ${escapeHtml(a.projectSlug)}</div>` : ''}
|
|
2265
|
+
</div>
|
|
2266
|
+
</div>
|
|
2267
|
+
</div>`;
|
|
2268
|
+
}).join('');
|
|
2269
|
+
alertsList.innerHTML = items;
|
|
2270
|
+
if (alertsZone) alertsZone.style.display = 'block';
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function filterCompanionView(filter) {
|
|
2274
|
+
const tabs = document.querySelectorAll('.companionTabs .tab');
|
|
2275
|
+
tabs.forEach(t => t.classList.remove('active'));
|
|
2276
|
+
document.querySelector(`.companionTabs .tab[data-filter="${filter}"]`)?.classList.add('active');
|
|
2277
|
+
const consolidated = $('companionConsolidated');
|
|
2278
|
+
const projects = $('companionProjects');
|
|
2279
|
+
const streams = $('streamBreakdown').parentElement.parentElement;
|
|
2280
|
+
const alerts = $('companionAlerts');
|
|
2281
|
+
if (filter === 'all') {
|
|
2282
|
+
if (consolidated) consolidated.style.display = 'grid';
|
|
2283
|
+
if (projects) projects.style.display = 'grid';
|
|
2284
|
+
if (streams) streams.style.display = 'block';
|
|
2285
|
+
if (alerts) alerts.style.display = 'none';
|
|
2286
|
+
} else if (filter === 'alerts') {
|
|
2287
|
+
if (consolidated) consolidated.style.display = 'none';
|
|
2288
|
+
if (projects) projects.style.display = 'none';
|
|
2289
|
+
if (streams) streams.style.display = 'none';
|
|
2290
|
+
if (alerts) alerts.style.display = 'block';
|
|
2291
|
+
} else if (filter === 'risk') {
|
|
2292
|
+
if (consolidated) consolidated.style.display = 'grid';
|
|
2293
|
+
if (projects) projects.style.display = 'grid';
|
|
2294
|
+
if (streams) streams.style.display = 'block';
|
|
2295
|
+
if (alerts) alerts.style.display = 'none';
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2138
2299
|
async function doHealth() {
|
|
2139
2300
|
try {
|
|
2140
2301
|
saveLocal();
|
|
@@ -2728,6 +2889,8 @@
|
|
|
2728
2889
|
window.refreshExecutiveSummary = refreshExecutiveSummary;
|
|
2729
2890
|
window.refreshAnomalies = refreshAnomalies;
|
|
2730
2891
|
window.refreshRiskRadar = refreshRiskRadar;
|
|
2892
|
+
window.refreshCompanionDash = refreshCompanionDash;
|
|
2893
|
+
window.filterCompanionView = filterCompanionView;
|
|
2731
2894
|
window.copyOut = copyOut;
|
|
2732
2895
|
window.copyPath = copyPath;
|
|
2733
2896
|
window.openSelected = openSelected;
|
package/cli/web.js
CHANGED
|
@@ -1797,97 +1797,42 @@ function buildCompanionHtml(safeDefault, appVersion) {
|
|
|
1797
1797
|
<section class="reportsHeader">
|
|
1798
1798
|
<div>
|
|
1799
1799
|
<div class="reportsTitle">Scrum Master Companion</div>
|
|
1800
|
-
<div class="reportsSubtitle">
|
|
1800
|
+
<div class="reportsSubtitle">Sprint Health Dashboard — Visão consolidada de seus projetos</div>
|
|
1801
1801
|
</div>
|
|
1802
1802
|
<div class="reportsActions">
|
|
1803
|
-
<button class="btn small" type="button" onclick="
|
|
1803
|
+
<button class="btn small" type="button" onclick="refreshCompanionDash()">↻ Atualizar</button>
|
|
1804
1804
|
</div>
|
|
1805
1805
|
</section>
|
|
1806
1806
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
<!-- Left Column / Block 1 -->
|
|
1815
|
-
<div style="display: flex; flex-direction: column; gap: 16px;">
|
|
1816
|
-
<section class="panel">
|
|
1817
|
-
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1818
|
-
<b>Resumo Executivo</b>
|
|
1819
|
-
<button class="btn small" type="button" onclick="refreshExecutiveSummary()">↻</button>
|
|
1820
|
-
</div>
|
|
1821
|
-
<div class="panelBody">
|
|
1822
|
-
<div id="executiveSummary" class="help"></div>
|
|
1823
|
-
</div>
|
|
1824
|
-
</section>
|
|
1825
|
-
|
|
1826
|
-
<section class="panel">
|
|
1827
|
-
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1828
|
-
<b>Radar de Risco</b>
|
|
1829
|
-
<button class="btn small" type="button" onclick="refreshRiskRadar()">↻</button>
|
|
1830
|
-
</div>
|
|
1831
|
-
<div class="panelBody">
|
|
1832
|
-
<div id="riskRadarBox"></div>
|
|
1833
|
-
</div>
|
|
1834
|
-
</section>
|
|
1835
|
-
|
|
1836
|
-
<section class="panel">
|
|
1837
|
-
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1838
|
-
<b>Task Heatmap</b>
|
|
1839
|
-
</div>
|
|
1840
|
-
<div class="panelBody">
|
|
1841
|
-
<div id="heatmapGrid"></div>
|
|
1842
|
-
</div>
|
|
1843
|
-
</section>
|
|
1844
|
-
</div>
|
|
1845
|
-
|
|
1846
|
-
<!-- Right Column / Block 2 -->
|
|
1847
|
-
<div style="display: flex; flex-direction: column; gap: 16px;">
|
|
1848
|
-
<section class="panel">
|
|
1849
|
-
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1850
|
-
<b style="color: var(--accent);">Saúde & Checklists</b>
|
|
1851
|
-
</div>
|
|
1852
|
-
<div class="panelBody" style="padding: 0;">
|
|
1853
|
-
<section class="reportsGrid" id="healthChecklist" style="gap: 0; border: none; box-shadow: none;"></section>
|
|
1854
|
-
</div>
|
|
1855
|
-
</section>
|
|
1807
|
+
<!-- TAB FILTERS -->
|
|
1808
|
+
<div class="companionTabs">
|
|
1809
|
+
<button class="tab active" data-filter="all" onclick="filterCompanionView('all')">○ Todos</button>
|
|
1810
|
+
<button class="tab" data-filter="alerts" onclick="filterCompanionView('alerts')">⚠️ Alertas</button>
|
|
1811
|
+
<button class="tab" data-filter="risk" onclick="filterCompanionView('risk')">🔴 Risco Alto</button>
|
|
1812
|
+
</div>
|
|
1856
1813
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1860
|
-
<b>Qualidade de Log</b>
|
|
1861
|
-
<button class="btn small" type="button" onclick="refreshQualityScore()">↻</button>
|
|
1862
|
-
</div>
|
|
1863
|
-
<div class="panelBody">
|
|
1864
|
-
<div id="qualityScoreCard"></div>
|
|
1865
|
-
</div>
|
|
1866
|
-
</section>
|
|
1814
|
+
<!-- CONSOLIDATED VIEW -->
|
|
1815
|
+
<div id="companionConsolidated" class="consolidatedView"></div>
|
|
1867
1816
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
<b>Anomalias</b>
|
|
1871
|
-
<button class="btn small" type="button" onclick="refreshAnomalies()">↻</button>
|
|
1872
|
-
</div>
|
|
1873
|
-
<div class="panelBody">
|
|
1874
|
-
<div id="anomaliesBox"></div>
|
|
1875
|
-
</div>
|
|
1876
|
-
</section>
|
|
1877
|
-
</div>
|
|
1878
|
-
</div>
|
|
1879
|
-
|
|
1880
|
-
<!-- Full Width Span -->
|
|
1881
|
-
<div style="grid-column: 1 / -1; display: flex; flex-direction: column; gap: 16px;">
|
|
1882
|
-
<section class="panel">
|
|
1883
|
-
<div class="panelHead"><b>Incident Radar</b></div>
|
|
1884
|
-
<div class="panelBody">
|
|
1885
|
-
<div id="incidentsBox" class="log md" style="font-family: var(--sans);"></div>
|
|
1886
|
-
</div>
|
|
1887
|
-
</section>
|
|
1817
|
+
<!-- PROJECT CARDS GRID -->
|
|
1818
|
+
<div id="companionProjects" class="projectCardsGrid"></div>
|
|
1888
1819
|
|
|
1820
|
+
<!-- STREAM BREAKDOWN -->
|
|
1821
|
+
<section class="panel" style="margin-top: 24px;">
|
|
1822
|
+
<div class="panelHead"><b>Distribuição por Frente/Stream</b></div>
|
|
1823
|
+
<div class="panelBody">
|
|
1824
|
+
<div id="streamBreakdown" class="help">Carregando streams...</div>
|
|
1889
1825
|
</div>
|
|
1826
|
+
</section>
|
|
1890
1827
|
|
|
1828
|
+
<!-- ALERTS ZONE -->
|
|
1829
|
+
<div id="companionAlerts" style="display: none; margin-top: 24px;">
|
|
1830
|
+
<section class="panel">
|
|
1831
|
+
<div class="panelHead"><b>⚠️ Alertas Prioritários</b></div>
|
|
1832
|
+
<div class="panelBody">
|
|
1833
|
+
<div id="alertsList"></div>
|
|
1834
|
+
</div>
|
|
1835
|
+
</section>
|
|
1891
1836
|
</div>
|
|
1892
1837
|
</div>
|
|
1893
1838
|
</main>
|
|
@@ -2053,6 +1998,18 @@ function truncateText(text, maxLen) {
|
|
|
2053
1998
|
return str.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
|
|
2054
1999
|
}
|
|
2055
2000
|
|
|
2001
|
+
function formatAgo(timestampMs) {
|
|
2002
|
+
const ms = typeof timestampMs === 'number' ? timestampMs : (typeof timestampMs === 'string' ? Date.parse(timestampMs) : 0);
|
|
2003
|
+
if (!Number.isFinite(ms)) return 'unknown';
|
|
2004
|
+
const now = Date.now();
|
|
2005
|
+
const diffMs = now - ms;
|
|
2006
|
+
if (diffMs < 60000) return 'agora';
|
|
2007
|
+
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m`;
|
|
2008
|
+
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h`;
|
|
2009
|
+
if (diffMs < 604800000) return `${Math.floor(diffMs / 86400000)}d`;
|
|
2010
|
+
return `${Math.floor(diffMs / 604800000)}w`;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2056
2013
|
function getTimelineItems(workspaceDir) {
|
|
2057
2014
|
const items = [];
|
|
2058
2015
|
const dailyDir = path.join(workspaceDir, 'logs', 'daily');
|
|
@@ -3709,6 +3666,202 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
3709
3666
|
return safeJson(res, 200, { ok: true, items, stats: { pendingTasks, openBlockers, reportsToday, reportsTotal: reports.length } });
|
|
3710
3667
|
}
|
|
3711
3668
|
|
|
3669
|
+
if (req.url === '/api/companion/projects-summary') {
|
|
3670
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
3671
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized', projects: [] });
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
const now = Date.now();
|
|
3675
|
+
const projects = {};
|
|
3676
|
+
|
|
3677
|
+
// Aggregate tasks by projectSlug
|
|
3678
|
+
const tasksRaw = dl.db.prepare('SELECT * FROM tasks WHERE status IN ("PENDING", "COMPLETED")').all();
|
|
3679
|
+
const blockersRaw = dl.db.prepare('SELECT * FROM blockers WHERE status IN ("OPEN", "MITIGATING")').all();
|
|
3680
|
+
|
|
3681
|
+
// Build project summary
|
|
3682
|
+
for (const t of tasksRaw) {
|
|
3683
|
+
const slug = String(t.project_slug || 'unassigned').trim();
|
|
3684
|
+
if (!projects[slug]) {
|
|
3685
|
+
projects[slug] = { slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, streams: new Set(), blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }, oldestBlockerDays: null, lastUpdateMs: null };
|
|
3686
|
+
}
|
|
3687
|
+
projects[slug].totalTasks++;
|
|
3688
|
+
if (String(t.status).toUpperCase() === 'COMPLETED') projects[slug].completedTasks++;
|
|
3689
|
+
else projects[slug].pendingTasks++;
|
|
3690
|
+
if (t.completed_at) projects[slug].lastUpdateMs = Math.max(projects[slug].lastUpdateMs || 0, Date.parse(t.completed_at));
|
|
3691
|
+
if (t.created_at) projects[slug].lastUpdateMs = Math.max(projects[slug].lastUpdateMs || 0, Date.parse(t.created_at));
|
|
3692
|
+
const meta = t.metadata ? JSON.parse(String(t.metadata || '{}')) : {};
|
|
3693
|
+
if (meta.streamSlug) projects[slug].streams.add(String(meta.streamSlug));
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
// Add blockers to projects
|
|
3697
|
+
for (const b of blockersRaw) {
|
|
3698
|
+
const slug = String(b.project_slug || 'unassigned').trim();
|
|
3699
|
+
if (!projects[slug]) {
|
|
3700
|
+
projects[slug] = { slug, totalTasks: 0, completedTasks: 0, pendingTasks: 0, streams: new Set(), blockersCount: 0, blockersBySeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }, oldestBlockerDays: null, lastUpdateMs: null };
|
|
3701
|
+
}
|
|
3702
|
+
projects[slug].blockersCount++;
|
|
3703
|
+
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
3704
|
+
if (projects[slug].blockersBySeverity[sev] !== undefined) projects[slug].blockersBySeverity[sev]++;
|
|
3705
|
+
const createdMs = b.created_at ? Date.parse(b.created_at) : null;
|
|
3706
|
+
if (createdMs) {
|
|
3707
|
+
projects[slug].lastUpdateMs = Math.max(projects[slug].lastUpdateMs || 0, createdMs);
|
|
3708
|
+
const ageDays = Math.floor((now - createdMs) / (24 * 60 * 60 * 1000));
|
|
3709
|
+
if (projects[slug].oldestBlockerDays === null || ageDays > projects[slug].oldestBlockerDays) {
|
|
3710
|
+
projects[slug].oldestBlockerDays = ageDays;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// Convert to array and calculate status + velocity
|
|
3716
|
+
const projectsArray = Object.values(projects).map(p => {
|
|
3717
|
+
const completedThisWeek = dl.db.prepare('SELECT count(*) as count FROM tasks WHERE project_slug = ? AND status = "COMPLETED" AND completed_at > ?').get(p.slug, new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString());
|
|
3718
|
+
const velocityThisWeek = completedThisWeek ? (completedThisWeek.count / 7).toFixed(1) : '0';
|
|
3719
|
+
const lastUpdateAgo = p.lastUpdateMs ? formatAgo(p.lastUpdateMs) : 'nunca';
|
|
3720
|
+
const hasOldBlockers = p.oldestBlockerDays && p.oldestBlockerDays > 7;
|
|
3721
|
+
const hasHighSeverity = p.blockersBySeverity.CRITICAL > 0 || p.blockersBySeverity.HIGH > 0;
|
|
3722
|
+
const isIdle = p.lastUpdateMs && (now - p.lastUpdateMs) > 7 * 24 * 60 * 60 * 1000;
|
|
3723
|
+
const status = isIdle ? 'IDLE' : (hasHighSeverity || hasOldBlockers ? 'AT_RISK' : 'ON_TRACK');
|
|
3724
|
+
return {
|
|
3725
|
+
slug: p.slug,
|
|
3726
|
+
name: p.slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
3727
|
+
totalTasks: p.totalTasks,
|
|
3728
|
+
completedTasks: p.completedTasks,
|
|
3729
|
+
pendingTasks: p.pendingTasks,
|
|
3730
|
+
blockersCount: p.blockersCount,
|
|
3731
|
+
blockersBySeverity: p.blockersBySeverity,
|
|
3732
|
+
oldestBlockerDays: p.oldestBlockerDays,
|
|
3733
|
+
velocityThisWeek: parseFloat(velocityThisWeek),
|
|
3734
|
+
lastUpdateAgo: lastUpdateAgo,
|
|
3735
|
+
status: status,
|
|
3736
|
+
streams: Array.from(p.streams)
|
|
3737
|
+
};
|
|
3738
|
+
});
|
|
3739
|
+
|
|
3740
|
+
return safeJson(res, 200, { ok: true, projects: projectsArray });
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
if (req.url === '/api/companion/streams-breakdown') {
|
|
3744
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
3745
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized', items: [] });
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
const tasks = dl.db.prepare('SELECT * FROM tasks').all();
|
|
3749
|
+
const blockers = dl.db.prepare('SELECT * FROM blockers').all();
|
|
3750
|
+
|
|
3751
|
+
const breakdown = {};
|
|
3752
|
+
for (const t of tasks) {
|
|
3753
|
+
const projectSlug = String(t.project_slug || 'unassigned');
|
|
3754
|
+
if (!breakdown[projectSlug]) breakdown[projectSlug] = {};
|
|
3755
|
+
const meta = t.metadata ? JSON.parse(String(t.metadata || '{}')) : {};
|
|
3756
|
+
const streamName = String(meta.streamSlug || 'Default');
|
|
3757
|
+
if (!breakdown[projectSlug][streamName]) {
|
|
3758
|
+
breakdown[projectSlug][streamName] = { totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersHighestSeverity: null };
|
|
3759
|
+
}
|
|
3760
|
+
breakdown[projectSlug][streamName].totalTasks++;
|
|
3761
|
+
if (String(t.status).toUpperCase() === 'COMPLETED') breakdown[projectSlug][streamName].completedTasks++;
|
|
3762
|
+
else breakdown[projectSlug][streamName].pendingTasks++;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
for (const b of blockers) {
|
|
3766
|
+
if (String(b.status).toUpperCase() !== 'OPEN' && String(b.status).toUpperCase() !== 'MITIGATING') continue;
|
|
3767
|
+
const projectSlug = String(b.project_slug || 'unassigned');
|
|
3768
|
+
if (!breakdown[projectSlug]) breakdown[projectSlug] = {};
|
|
3769
|
+
const meta = b.metadata ? JSON.parse(String(b.metadata || '{}')) : {};
|
|
3770
|
+
const streamName = String(meta.streamSlug || 'Default');
|
|
3771
|
+
if (!breakdown[projectSlug][streamName]) {
|
|
3772
|
+
breakdown[projectSlug][streamName] = { totalTasks: 0, completedTasks: 0, pendingTasks: 0, blockersCount: 0, blockersHighestSeverity: null };
|
|
3773
|
+
}
|
|
3774
|
+
breakdown[projectSlug][streamName].blockersCount++;
|
|
3775
|
+
const sev = String(b.severity || 'LOW').toUpperCase();
|
|
3776
|
+
const sevOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
3777
|
+
const sevVal = sevOrder[sev] !== undefined ? sevOrder[sev] : 999;
|
|
3778
|
+
const currentVal = breakdown[projectSlug][streamName].blockersHighestSeverity ? sevOrder[breakdown[projectSlug][streamName].blockersHighestSeverity] : 999;
|
|
3779
|
+
if (sevVal < currentVal) breakdown[projectSlug][streamName].blockersHighestSeverity = sev;
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
const items = Object.entries(breakdown).map(([projectSlug, streams]) => ({
|
|
3783
|
+
projectSlug,
|
|
3784
|
+
projectName: projectSlug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
3785
|
+
streams: Object.entries(streams).map(([streamName, data]) => ({ streamName, ...data }))
|
|
3786
|
+
}));
|
|
3787
|
+
|
|
3788
|
+
return safeJson(res, 200, { ok: true, items });
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
if (req.url === '/api/companion/alerts') {
|
|
3792
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
3793
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized', alerts: [] });
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
const now = Date.now();
|
|
3797
|
+
const alerts = [];
|
|
3798
|
+
|
|
3799
|
+
const blockers = dl.db.prepare('SELECT * FROM blockers WHERE status IN ("OPEN", "MITIGATING")').all();
|
|
3800
|
+
const tasks = dl.db.prepare('SELECT * FROM tasks WHERE status = "PENDING"').all();
|
|
3801
|
+
|
|
3802
|
+
// Old blockers
|
|
3803
|
+
for (const b of blockers) {
|
|
3804
|
+
const createdMs = b.created_at ? Date.parse(b.created_at) : null;
|
|
3805
|
+
if (createdMs) {
|
|
3806
|
+
const ageDays = Math.floor((now - createdMs) / (24 * 60 * 60 * 1000));
|
|
3807
|
+
if (ageDays > 3) {
|
|
3808
|
+
const sev = String(b.severity || '').toUpperCase();
|
|
3809
|
+
alerts.push({
|
|
3810
|
+
severity: sev === 'CRITICAL' ? 'CRITICAL' : sev === 'HIGH' ? 'HIGH' : 'MEDIUM',
|
|
3811
|
+
type: 'old_blocker',
|
|
3812
|
+
projectSlug: String(b.project_slug || ''),
|
|
3813
|
+
streamName: 'blocker: ' + (b.title || ''),
|
|
3814
|
+
message: `Blocker aberto há ${ageDays} dias: ${b.title || 'Sem titulo'}`,
|
|
3815
|
+
ageDays
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
// Stale projects (7+ days without update)
|
|
3822
|
+
const projects = {};
|
|
3823
|
+
for (const t of tasks) {
|
|
3824
|
+
const slug = String(t.project_slug || '');
|
|
3825
|
+
if (slug) {
|
|
3826
|
+
const createdMs = t.created_at ? Date.parse(t.created_at) : null;
|
|
3827
|
+
if (createdMs && (!projects[slug] || createdMs > projects[slug])) projects[slug] = createdMs;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
for (const b of blockers) {
|
|
3831
|
+
const slug = String(b.project_slug || '');
|
|
3832
|
+
if (slug) {
|
|
3833
|
+
const createdMs = b.created_at ? Date.parse(b.created_at) : null;
|
|
3834
|
+
if (createdMs && (!projects[slug] || createdMs > projects[slug])) projects[slug] = createdMs;
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
for (const [slug, lastMs] of Object.entries(projects)) {
|
|
3838
|
+
if (lastMs) {
|
|
3839
|
+
const ageDays = Math.floor((now - lastMs) / (24 * 60 * 60 * 1000));
|
|
3840
|
+
if (ageDays > 7) {
|
|
3841
|
+
alerts.push({
|
|
3842
|
+
severity: ageDays > 14 ? 'HIGH' : 'MEDIUM',
|
|
3843
|
+
type: 'stale_project',
|
|
3844
|
+
projectSlug: slug,
|
|
3845
|
+
streamName: '',
|
|
3846
|
+
message: `Projeto ${slug} sem updates há ${ageDays} dias`,
|
|
3847
|
+
ageDays
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
// Sort by severity + age
|
|
3854
|
+
alerts.sort((a, b) => {
|
|
3855
|
+
const sevOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2 };
|
|
3856
|
+
const aVal = sevOrder[a.severity] || 99;
|
|
3857
|
+
const bVal = sevOrder[b.severity] || 99;
|
|
3858
|
+
if (aVal !== bVal) return aVal - bVal;
|
|
3859
|
+
return (b.ageDays || 0) - (a.ageDays || 0);
|
|
3860
|
+
});
|
|
3861
|
+
|
|
3862
|
+
return safeJson(res, 200, { ok: true, alerts: alerts.slice(0, 10) });
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3712
3865
|
// BUG-12: duplicate handlers for /api/incidents/resolve, /api/incidents,
|
|
3713
3866
|
// and /api/tasks/heatmap were removed here — originals remain earlier in the file.
|
|
3714
3867
|
|
package/package.json
CHANGED