@cccarv82/freya 2.3.7 → 2.3.8
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.js +72 -0
- package/cli/web.js +189 -7
- package/package.json +1 -1
package/cli/web-ui.js
CHANGED
|
@@ -1268,6 +1268,74 @@
|
|
|
1268
1268
|
}
|
|
1269
1269
|
}
|
|
1270
1270
|
|
|
1271
|
+
async function refreshQualityScore() {
|
|
1272
|
+
const el = $('qualityScoreCard');
|
|
1273
|
+
if (el) el.innerHTML = '<div class="help">Carregando score...</div>';
|
|
1274
|
+
try {
|
|
1275
|
+
const r = await api('/api/quality/score', { dir: dirOrDefault() });
|
|
1276
|
+
if (!el) return;
|
|
1277
|
+
if (r && r.needsInit) {
|
|
1278
|
+
el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const score = (r && typeof r.score === 'number') ? r.score : null;
|
|
1282
|
+
const breakdown = (r && r.breakdown) ? r.breakdown : {};
|
|
1283
|
+
const threshold = 90;
|
|
1284
|
+
const status = (score !== null && score >= threshold) ? 'ok' : 'warn';
|
|
1285
|
+
|
|
1286
|
+
const line = (label, data, keyLabel) => {
|
|
1287
|
+
if (!data) return '';
|
|
1288
|
+
const pct = (typeof data.pct === 'number') ? `${data.pct}%` : 'n/a';
|
|
1289
|
+
const detail = keyLabel ? `${data[keyLabel] || 0}/${data.total || 0}` : `${data.total || 0}`;
|
|
1290
|
+
return `<div class=\"help\" style=\"margin-top:4px\"><b>${escapeHtml(label)}:</b> ${escapeHtml(pct)} (${escapeHtml(detail)})</div>`;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
const html = `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
|
|
1294
|
+
+ `<div style=\"min-width:0\"><div style=\"font-weight:800\">${score === null ? 'Sem score' : `Score: ${score}%`}</div>`
|
|
1295
|
+
+ `${line('Tasks com projectSlug', breakdown.tasks, 'withProjectSlug')}`
|
|
1296
|
+
+ `${line('Status com history', breakdown.status, 'withHistory')}`
|
|
1297
|
+
+ `${line('Blockers com projectSlug', breakdown.blockers, 'withProjectSlug')}`
|
|
1298
|
+
+ `</div>`
|
|
1299
|
+
+ `<div class=\"pill ${status}\">${status}</div>`
|
|
1300
|
+
+ `</div>`;
|
|
1301
|
+
el.innerHTML = html;
|
|
1302
|
+
} catch {
|
|
1303
|
+
if (el) el.innerHTML = '<div class="help">Falha ao carregar score.</div>';
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function refreshRiskSummary() {
|
|
1308
|
+
const el = $('riskSummary');
|
|
1309
|
+
if (el) el.innerHTML = '<div class="help">Carregando riscos...</div>';
|
|
1310
|
+
try {
|
|
1311
|
+
const r = await api('/api/risk/summary', { dir: dirOrDefault() });
|
|
1312
|
+
if (!el) return;
|
|
1313
|
+
if (r && r.needsInit) {
|
|
1314
|
+
el.innerHTML = '<div class="help">Workspace não inicializado.</div>';
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const items = Array.isArray(r.items) ? r.items : [];
|
|
1318
|
+
if (!items.length) {
|
|
1319
|
+
el.innerHTML = '<div class="help">Sem riscos relevantes.</div>';
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const rows = items.map((it) => {
|
|
1323
|
+
const age = (it.oldestBlockerDays != null) ? `${it.oldestBlockerDays}d` : 'n/a';
|
|
1324
|
+
return `<div class=\"rep\">`
|
|
1325
|
+
+ `<div style=\"display:flex; justify-content:space-between; gap:10px; align-items:center\">`
|
|
1326
|
+
+ `<div style=\"min-width:0\"><div style=\"font-weight:800\">${escapeHtml(it.slug || '')}</div>`
|
|
1327
|
+
+ `<div class=\"help\" style=\"margin-top:4px\">Pendentes: ${escapeHtml(String(it.pendingTasks || 0))} · Blockers 7d+: ${escapeHtml(String(it.oldBlockers || 0))} · Mais antigo: ${escapeHtml(age)}</div>`
|
|
1328
|
+
+ `</div>`
|
|
1329
|
+
+ `<div class=\"pill warn\">risco</div>`
|
|
1330
|
+
+ `</div>`
|
|
1331
|
+
+ `</div>`;
|
|
1332
|
+
}).join('');
|
|
1333
|
+
el.innerHTML = rows;
|
|
1334
|
+
} catch {
|
|
1335
|
+
if (el) el.innerHTML = '<div class="help">Falha ao carregar riscos.</div>';
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1271
1339
|
async function refreshExecutiveSummary() {
|
|
1272
1340
|
const el = $('executiveSummary');
|
|
1273
1341
|
if (el) el.textContent = 'Carregando resumo...';
|
|
@@ -1706,8 +1774,10 @@
|
|
|
1706
1774
|
|
|
1707
1775
|
if (isCompanionPage) {
|
|
1708
1776
|
await refreshHealthChecklist();
|
|
1777
|
+
await refreshQualityScore();
|
|
1709
1778
|
await refreshExecutiveSummary();
|
|
1710
1779
|
await refreshAnomalies();
|
|
1780
|
+
await refreshRiskSummary();
|
|
1711
1781
|
await refreshIncidents();
|
|
1712
1782
|
await refreshHeatmap();
|
|
1713
1783
|
return;
|
|
@@ -1759,8 +1829,10 @@
|
|
|
1759
1829
|
window.setTimelineKind = setTimelineKind;
|
|
1760
1830
|
window.refreshBlockersInsights = refreshBlockersInsights;
|
|
1761
1831
|
window.refreshHealthChecklist = refreshHealthChecklist;
|
|
1832
|
+
window.refreshQualityScore = refreshQualityScore;
|
|
1762
1833
|
window.refreshExecutiveSummary = refreshExecutiveSummary;
|
|
1763
1834
|
window.refreshAnomalies = refreshAnomalies;
|
|
1835
|
+
window.refreshRiskSummary = refreshRiskSummary;
|
|
1764
1836
|
window.copyOut = copyOut;
|
|
1765
1837
|
window.copyPath = copyPath;
|
|
1766
1838
|
window.openSelected = openSelected;
|
package/cli/web.js
CHANGED
|
@@ -1499,6 +1499,16 @@ function buildCompanionHtml(safeDefault, appVersion) {
|
|
|
1499
1499
|
|
|
1500
1500
|
<section class="reportsGrid" id="healthChecklist"></section>
|
|
1501
1501
|
|
|
1502
|
+
<section class="panel" style="margin-top:16px">
|
|
1503
|
+
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1504
|
+
<b>Qualidade de Log</b>
|
|
1505
|
+
<button class="btn small" type="button" onclick="refreshQualityScore()">Atualizar</button>
|
|
1506
|
+
</div>
|
|
1507
|
+
<div class="panelBody">
|
|
1508
|
+
<div id="qualityScoreCard"></div>
|
|
1509
|
+
</div>
|
|
1510
|
+
</section>
|
|
1511
|
+
|
|
1502
1512
|
<section class="panel" style="margin-top:16px">
|
|
1503
1513
|
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1504
1514
|
<b>Resumo Executivo</b>
|
|
@@ -1519,6 +1529,16 @@ function buildCompanionHtml(safeDefault, appVersion) {
|
|
|
1519
1529
|
</div>
|
|
1520
1530
|
</section>
|
|
1521
1531
|
|
|
1532
|
+
<section class="panel" style="margin-top:16px">
|
|
1533
|
+
<div class="panelHead" style="display:flex; align-items:center; justify-content:space-between; gap:10px">
|
|
1534
|
+
<b>Resumo de Risco</b>
|
|
1535
|
+
<button class="btn small" type="button" onclick="refreshRiskSummary()">Atualizar</button>
|
|
1536
|
+
</div>
|
|
1537
|
+
<div class="panelBody">
|
|
1538
|
+
<div id="riskSummary"></div>
|
|
1539
|
+
</div>
|
|
1540
|
+
</section>
|
|
1541
|
+
|
|
1522
1542
|
<section class="panel" style="margin-top:16px">
|
|
1523
1543
|
<div class="panelHead"><b>Incident Radar</b></div>
|
|
1524
1544
|
<div class="panelBody">
|
|
@@ -2112,6 +2132,136 @@ if (req.url === '/api/timeline') {
|
|
|
2112
2132
|
return safeJson(res, 200, { ok: true, summary, stats: { recent: recent.length, openBlockers: openBlockers.length, ...counts } });
|
|
2113
2133
|
}
|
|
2114
2134
|
|
|
2135
|
+
if (req.url === '/api/quality/score') {
|
|
2136
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
2137
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const pct = (count, total) => (total > 0 ? Math.round((count / total) * 1000) / 10 : null);
|
|
2141
|
+
|
|
2142
|
+
// Tasks with projectSlug
|
|
2143
|
+
let tasksTotal = 0;
|
|
2144
|
+
let tasksWithProject = 0;
|
|
2145
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2146
|
+
if (exists(taskFile)) {
|
|
2147
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2148
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2149
|
+
tasksTotal = tasks.length;
|
|
2150
|
+
for (const t of tasks) {
|
|
2151
|
+
const slug = String(t && t.projectSlug ? t.projectSlug : '').trim();
|
|
2152
|
+
if (slug) tasksWithProject++;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Status files with history array
|
|
2157
|
+
let statusTotal = 0;
|
|
2158
|
+
let statusWithHistory = 0;
|
|
2159
|
+
const base = path.join(workspaceDir, 'data', 'Clients');
|
|
2160
|
+
if (exists(base)) {
|
|
2161
|
+
const stack = [base];
|
|
2162
|
+
while (stack.length) {
|
|
2163
|
+
const dirp = stack.pop();
|
|
2164
|
+
const entries = fs.readdirSync(dirp, { withFileTypes: true });
|
|
2165
|
+
for (const ent of entries) {
|
|
2166
|
+
const full = path.join(dirp, ent.name);
|
|
2167
|
+
if (ent.isDirectory()) stack.push(full);
|
|
2168
|
+
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2169
|
+
statusTotal++;
|
|
2170
|
+
const doc = readJsonOrNull(full) || {};
|
|
2171
|
+
if (Array.isArray(doc.history)) statusWithHistory++;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// Blockers with projectSlug
|
|
2178
|
+
let blockersTotal = 0;
|
|
2179
|
+
let blockersWithProject = 0;
|
|
2180
|
+
const blockersFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2181
|
+
if (exists(blockersFile)) {
|
|
2182
|
+
const blockersDoc = readJsonOrNull(blockersFile) || { blockers: [] };
|
|
2183
|
+
const blockers = Array.isArray(blockersDoc.blockers) ? blockersDoc.blockers : [];
|
|
2184
|
+
blockersTotal = blockers.length;
|
|
2185
|
+
for (const b of blockers) {
|
|
2186
|
+
const slug = String(b && b.projectSlug ? b.projectSlug : '').trim();
|
|
2187
|
+
if (slug) blockersWithProject++;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
const breakdown = {
|
|
2192
|
+
tasks: { total: tasksTotal, withProjectSlug: tasksWithProject, pct: pct(tasksWithProject, tasksTotal) },
|
|
2193
|
+
status: { total: statusTotal, withHistory: statusWithHistory, pct: pct(statusWithHistory, statusTotal) },
|
|
2194
|
+
blockers: { total: blockersTotal, withProjectSlug: blockersWithProject, pct: pct(blockersWithProject, blockersTotal) }
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
const scoreParts = [breakdown.tasks.pct, breakdown.status.pct, breakdown.blockers.pct].filter((v) => typeof v === 'number');
|
|
2198
|
+
const score = scoreParts.length ? Math.round((scoreParts.reduce((a, b) => a + b, 0) / scoreParts.length) * 10) / 10 : null;
|
|
2199
|
+
|
|
2200
|
+
return safeJson(res, 200, { ok: true, score, breakdown });
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
if (req.url === '/api/risk/summary') {
|
|
2204
|
+
if (!looksLikeFreyaWorkspace(workspaceDir)) {
|
|
2205
|
+
return safeJson(res, 200, { ok: false, needsInit: true, error: 'Workspace not initialized' });
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const pendingThreshold = 5;
|
|
2209
|
+
const daysThreshold = 7;
|
|
2210
|
+
const now = Date.now();
|
|
2211
|
+
|
|
2212
|
+
const pendingByProject = {};
|
|
2213
|
+
const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
2214
|
+
if (exists(taskFile)) {
|
|
2215
|
+
const taskDoc = readJsonOrNull(taskFile) || { tasks: [] };
|
|
2216
|
+
const tasks = Array.isArray(taskDoc.tasks) ? taskDoc.tasks : [];
|
|
2217
|
+
for (const t of tasks) {
|
|
2218
|
+
if (!t || t.status === 'COMPLETED') continue;
|
|
2219
|
+
const slug = String(t.projectSlug || '').trim();
|
|
2220
|
+
if (!slug) continue;
|
|
2221
|
+
pendingByProject[slug] = (pendingByProject[slug] || 0) + 1;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
const blockersByProject = {};
|
|
2226
|
+
const oldestByProject = {};
|
|
2227
|
+
const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
2228
|
+
if (exists(blockerFile)) {
|
|
2229
|
+
const blockerDoc = readJsonOrNull(blockerFile) || { blockers: [] };
|
|
2230
|
+
const blockers = Array.isArray(blockerDoc.blockers) ? blockerDoc.blockers : [];
|
|
2231
|
+
for (const b of blockers) {
|
|
2232
|
+
if (!b) continue;
|
|
2233
|
+
const status = String(b.status || '').toUpperCase();
|
|
2234
|
+
if (status !== 'OPEN' && status !== 'MITIGATING') continue;
|
|
2235
|
+
const slug = String(b.projectSlug || '').trim();
|
|
2236
|
+
if (!slug) continue;
|
|
2237
|
+
const createdAt = b.createdAt ? Date.parse(b.createdAt) : null;
|
|
2238
|
+
if (!createdAt) continue;
|
|
2239
|
+
const ageDays = Math.floor((now - createdAt) / (24 * 60 * 60 * 1000));
|
|
2240
|
+
if (ageDays < daysThreshold) continue;
|
|
2241
|
+
blockersByProject[slug] = (blockersByProject[slug] || 0) + 1;
|
|
2242
|
+
if (oldestByProject[slug] == null || ageDays > oldestByProject[slug]) oldestByProject[slug] = ageDays;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const projects = new Set([...Object.keys(pendingByProject), ...Object.keys(blockersByProject)]);
|
|
2247
|
+
const items = [];
|
|
2248
|
+
for (const slug of projects) {
|
|
2249
|
+
const pending = pendingByProject[slug] || 0;
|
|
2250
|
+
const oldBlockers = blockersByProject[slug] || 0;
|
|
2251
|
+
const oldestDays = oldestByProject[slug] || null;
|
|
2252
|
+
if (pending <= pendingThreshold && oldBlockers === 0) continue;
|
|
2253
|
+
items.push({ slug, pendingTasks: pending, oldBlockers, oldestBlockerDays: oldestDays });
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
items.sort((a, b) => {
|
|
2257
|
+
if (b.oldBlockers !== a.oldBlockers) return b.oldBlockers - a.oldBlockers;
|
|
2258
|
+
if (b.pendingTasks !== a.pendingTasks) return b.pendingTasks - a.pendingTasks;
|
|
2259
|
+
return (b.oldestBlockerDays || 0) - (a.oldestBlockerDays || 0);
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
return safeJson(res, 200, { ok: true, items: items.slice(0, 5), threshold: { pending: pendingThreshold, days: daysThreshold } });
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2115
2265
|
if (req.url === '/api/anomalies') {
|
|
2116
2266
|
const anomalies = {
|
|
2117
2267
|
tasksMissingProject: { count: 0, samples: [] },
|
|
@@ -2269,6 +2419,7 @@ if (req.url === '/api/reports/list') {
|
|
|
2269
2419
|
const lc = textInput.toLowerCase();
|
|
2270
2420
|
const projectsDir = path.join(workspaceDir, 'docs', 'projects');
|
|
2271
2421
|
const links = [];
|
|
2422
|
+
const slugs = [];
|
|
2272
2423
|
|
|
2273
2424
|
if (exists(projectsDir)) {
|
|
2274
2425
|
const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith('.md'));
|
|
@@ -2278,7 +2429,10 @@ if (req.url === '/api/reports/list') {
|
|
|
2278
2429
|
const txt = fs.readFileSync(full, 'utf8');
|
|
2279
2430
|
const m = txt.match(/DataPath:\s*data\/Clients\/(.+?)\//i);
|
|
2280
2431
|
const slug = m ? m[1].toLowerCase() : name.toLowerCase();
|
|
2281
|
-
if (lc.includes(slug))
|
|
2432
|
+
if (lc.includes(slug)) {
|
|
2433
|
+
links.push('[[' + name + ']]');
|
|
2434
|
+
slugs.push(slug);
|
|
2435
|
+
}
|
|
2282
2436
|
}
|
|
2283
2437
|
}
|
|
2284
2438
|
|
|
@@ -2293,17 +2447,21 @@ if (req.url === '/api/reports/list') {
|
|
|
2293
2447
|
if (ent.isDirectory()) stack.push(full);
|
|
2294
2448
|
else if (ent.isFile() && ent.name === 'status.json') {
|
|
2295
2449
|
const slug = path.relative(base, path.dirname(full)).replace(/\\/g, '/').toLowerCase();
|
|
2296
|
-
if (lc.includes(slug))
|
|
2450
|
+
if (lc.includes(slug)) {
|
|
2451
|
+
links.push('[[' + slug + ']]');
|
|
2452
|
+
slugs.push(slug);
|
|
2453
|
+
}
|
|
2297
2454
|
}
|
|
2298
2455
|
}
|
|
2299
2456
|
}
|
|
2300
2457
|
}
|
|
2301
2458
|
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
|
|
2459
|
+
const uniqLinks = Array.from(new Set(links));
|
|
2460
|
+
const uniqSlugs = Array.from(new Set(slugs));
|
|
2461
|
+
const linksText = uniqLinks.length ? ('\n\nLinks: ' + uniqLinks.join(' ')) : '';
|
|
2462
|
+
return { linksText, slugs: uniqSlugs };
|
|
2305
2463
|
} catch {
|
|
2306
|
-
return '';
|
|
2464
|
+
return { linksText: '', slugs: [] };
|
|
2307
2465
|
}
|
|
2308
2466
|
}
|
|
2309
2467
|
|
|
@@ -2319,9 +2477,33 @@ if (req.url === '/api/reports/list') {
|
|
|
2319
2477
|
const hh = String(stamp.getHours()).padStart(2, '0');
|
|
2320
2478
|
const mm = String(stamp.getMinutes()).padStart(2, '0');
|
|
2321
2479
|
|
|
2322
|
-
const
|
|
2480
|
+
const linkInfo = autoLinkNotes(text);
|
|
2481
|
+
const linksText = linkInfo && linkInfo.linksText ? linkInfo.linksText : '';
|
|
2482
|
+
const slugs = linkInfo && Array.isArray(linkInfo.slugs) ? linkInfo.slugs : [];
|
|
2483
|
+
|
|
2484
|
+
const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}${linksText}\n`;
|
|
2323
2485
|
fs.appendFileSync(file, block, 'utf8');
|
|
2324
2486
|
|
|
2487
|
+
if (slugs.length) {
|
|
2488
|
+
const logRel = path.relative(workspaceDir, file).replace(/\\/g, '/');
|
|
2489
|
+
const stampText = `${d} ${hh}:${mm}`;
|
|
2490
|
+
for (const slug of slugs) {
|
|
2491
|
+
const statusPath = path.join(workspaceDir, 'data', 'Clients', slug, 'status.json');
|
|
2492
|
+
if (!exists(statusPath)) continue;
|
|
2493
|
+
const doc = readJsonOrNull(statusPath) || { history: [] };
|
|
2494
|
+
if (!Array.isArray(doc.history)) doc.history = [];
|
|
2495
|
+
const already = doc.history.some((h) => h && (String(h.source || '').includes(logRel) || String(h.content || '').includes(logRel)));
|
|
2496
|
+
if (already) continue;
|
|
2497
|
+
doc.history.push({
|
|
2498
|
+
date: isoNow(),
|
|
2499
|
+
type: 'Log',
|
|
2500
|
+
content: `Log entry ${stampText} (${logRel})`,
|
|
2501
|
+
source: logRel
|
|
2502
|
+
});
|
|
2503
|
+
writeJson(statusPath, doc);
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2325
2507
|
return safeJson(res, 200, { ok: true, file: path.relative(workspaceDir, file).replace(/\\/g, '/'), appended: true });
|
|
2326
2508
|
}
|
|
2327
2509
|
|
package/package.json
CHANGED