05ed037fe8
- DNS: pilot.landvex.com -> 16.170.83.169 - TLS: Let's Encrypt certificate (expires 2026-09-30) - Nginx: reverse proxy with SSL termination - API: https://pilot.landvex.com/api/v1/missions - UI: https://pilot.landvex.com/ - Upload: POST /api/v1/missions/import (multipart/form-data) Verified: ✅ https://pilot.landvex.com/health ✅ https://pilot.landvex.com/version ✅ https://pilot.landvex.com/api/v1/missions (list) ✅ https://pilot.landvex.com/api/v1/missions/:id (get) ✅ POST /api/v1/missions/import (video upload) ✅ UI loads with title 'LandveX Intelligence Lab' Next: Pilot 001 — Break the system!
442 lines
16 KiB
JavaScript
442 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// SIL Observability — Dashboard och metriker för SIL självt
|
|
// Erik-krav: "Bygg dashboards och mätvärden för SIL, inte bara för produkten"
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
import { execSync } from 'child_process';
|
|
|
|
const LOGS = {
|
|
shadow: '/home/bernt/.openclaw/workspace/SIL/shadow-log.jsonl',
|
|
gold: '/home/bernt/.openclaw/workspace/SIL/gold-set.jsonl',
|
|
stability: '/home/bernt/.openclaw/workspace/SIL/stability-log.jsonl',
|
|
versions: '/home/bernt/.openclaw/workspace/SIL/graph-version-log.jsonl',
|
|
webhooks: '/home/bernt/.openclaw/workspace/SIL/webhook-events.jsonl',
|
|
decisions: '/home/bernt/.openclaw/workspace/SIL/decision-log.jsonl'
|
|
};
|
|
|
|
const DASHBOARD_DIR = '/home/bernt/.openclaw/workspace/SIL/dashboard';
|
|
|
|
class SILObservability {
|
|
constructor() {
|
|
if (!existsSync(DASHBOARD_DIR)) {
|
|
mkdirSync(DASHBOARD_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
loadLog(path) {
|
|
if (!existsSync(path)) return [];
|
|
return readFileSync(path, 'utf8')
|
|
.split('\n')
|
|
.filter(line => line.trim())
|
|
.map(line => {
|
|
try { return JSON.parse(line); } catch { return null; }
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// ── Daglig sammanfattning ───────────────────────────────────────────────
|
|
|
|
generateDailyReport() {
|
|
const now = new Date();
|
|
const today = now.toISOString().split('T')[0];
|
|
|
|
const shadowEntries = this.loadLog(LOGS.shadow);
|
|
const goldEntries = this.loadLog(LOGS.gold);
|
|
const stabilityEntries = this.loadLog(LOGS.stability);
|
|
const versionEntries = this.loadLog(LOGS.versions);
|
|
const webhookEntries = this.loadLog(LOGS.webhooks);
|
|
|
|
// Filtrera dagens entries
|
|
const todayEntries = shadowEntries.filter(e =>
|
|
e.timestamp?.startsWith(today)
|
|
);
|
|
|
|
const report = {
|
|
date: today,
|
|
generatedAt: now.toISOString(),
|
|
|
|
// Beslut
|
|
decisions: {
|
|
total: todayEntries.length,
|
|
byType: this.countBy(todayEntries, 'status'),
|
|
laterDisproven: this.countDisproven(goldEntries, today)
|
|
},
|
|
|
|
// Osäkerhet
|
|
uncertainty: {
|
|
unknown: todayEntries.filter(e => e.uncertaintySummary?.unknown > 0).length,
|
|
uncertain: todayEntries.filter(e => e.uncertaintySummary?.uncertain > 0).length,
|
|
certain: todayEntries.filter(e => e.uncertaintySummary?.certain > 0).length
|
|
},
|
|
|
|
// Graf-hälsa
|
|
graph: {
|
|
versions: versionEntries.length,
|
|
latestVersion: versionEntries[versionEntries.length - 1]?.id || 'none',
|
|
nodes: versionEntries[versionEntries.length - 1]?.metadata?.nodeCount || 0,
|
|
edges: versionEntries[versionEntries.length - 1]?.metadata?.edgeCount || 0
|
|
},
|
|
|
|
// Stabilitet
|
|
stability: {
|
|
testsRun: stabilityEntries.length,
|
|
deterministic: stabilityEntries.filter(e =>
|
|
e.stability?.isDeterministic
|
|
).length,
|
|
avgStability: this.calculateAvgStability(stabilityEntries)
|
|
},
|
|
|
|
// Webhooks
|
|
webhooks: {
|
|
events: webhookEntries.filter(e =>
|
|
e.timestamp?.startsWith(today)
|
|
).length,
|
|
queued: webhookEntries.filter(e =>
|
|
e.status === 'queued' && e.timestamp?.startsWith(today)
|
|
).length,
|
|
completed: webhookEntries.filter(e =>
|
|
e.status === 'completed' && e.timestamp?.startsWith(today)
|
|
).length
|
|
},
|
|
|
|
// Kvalitetsmetriker över tid
|
|
quality: this.calculateQualityTrend(goldEntries)
|
|
};
|
|
|
|
this.saveDailyReport(report);
|
|
this.printDailyReport(report);
|
|
|
|
return report;
|
|
}
|
|
|
|
// ── Veckotrend ──────────────────────────────────────────────────────────
|
|
|
|
calculateQualityTrend(goldEntries) {
|
|
if (goldEntries.length === 0) return null;
|
|
|
|
// Gruppera per vecka
|
|
const byWeek = {};
|
|
for (const entry of goldEntries) {
|
|
const date = new Date(entry.date || entry.timestamp);
|
|
const week = this.getWeekKey(date);
|
|
|
|
if (!byWeek[week]) {
|
|
byWeek[week] = { entries: [], tp: 0, fp: 0, fn: 0 };
|
|
}
|
|
|
|
byWeek[week].entries.push(entry);
|
|
|
|
const predicted = new Set(entry.predicted || []);
|
|
const actual = new Set(entry.actual || []);
|
|
|
|
for (const comp of actual) {
|
|
if (predicted.has(comp)) byWeek[week].tp++;
|
|
else byWeek[week].fn++;
|
|
}
|
|
|
|
for (const comp of predicted) {
|
|
if (!actual.has(comp)) byWeek[week].fp++;
|
|
}
|
|
}
|
|
|
|
const trends = [];
|
|
for (const [week, data] of Object.entries(byWeek).sort()) {
|
|
const recall = data.tp + data.fn > 0 ? data.tp / (data.tp + data.fn) : 0;
|
|
const precision = data.tp + data.fp > 0 ? data.tp / (data.tp + data.fp) : 0;
|
|
|
|
trends.push({
|
|
week,
|
|
prs: data.entries.length,
|
|
recall: Math.round(recall * 100) + '%',
|
|
precision: Math.round(precision * 100) + '%',
|
|
fnr: data.tp + data.fn > 0 ? Math.round(data.fn / (data.tp + data.fn) * 100) + '%' : 'N/A'
|
|
});
|
|
}
|
|
|
|
return trends;
|
|
}
|
|
|
|
// ── Regelanvändning ─────────────────────────────────────────────────────
|
|
|
|
analyzeRuleUsage() {
|
|
const shadowEntries = this.loadLog(LOGS.shadow);
|
|
|
|
const ruleCounts = {};
|
|
const unusedRules = new Set([
|
|
'auth', 'wallet', 'mission', 'kyc', 'user',
|
|
'notification', 'api', 'database', 'tests', 'config', 'frontend', 'infrastructure'
|
|
]);
|
|
|
|
for (const entry of shadowEntries) {
|
|
const components = entry.components || entry.changedComponents || [];
|
|
if (!Array.isArray(components)) continue;
|
|
for (const comp of components) {
|
|
const compId = typeof comp === 'string' ? comp : comp.id;
|
|
if (compId) {
|
|
ruleCounts[compId] = (ruleCounts[compId] || 0) + 1;
|
|
unusedRules.delete(compId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
used: Object.entries(ruleCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([rule, count]) => ({ rule, count })),
|
|
unused: [...unusedRules],
|
|
totalRules: 12,
|
|
utilizationRate: Math.round((12 - unusedRules.size) / 12 * 100) + '%'
|
|
};
|
|
}
|
|
|
|
// ── Confidence-fördelning ───────────────────────────────────────────────
|
|
|
|
analyzeConfidence() {
|
|
const shadowEntries = this.loadLog(LOGS.shadow);
|
|
|
|
const distribution = {
|
|
'0.0-0.5': 0, '0.5-0.7': 0, '0.7-0.9': 0, '0.9-1.0': 0
|
|
};
|
|
|
|
for (const entry of shadowEntries) {
|
|
const conf = entry.confidence || 0;
|
|
if (conf >= 0.9) distribution['0.9-1.0']++;
|
|
else if (conf >= 0.7) distribution['0.7-0.9']++;
|
|
else if (conf >= 0.5) distribution['0.5-0.7']++;
|
|
else distribution['0.0-0.5']++;
|
|
}
|
|
|
|
return distribution;
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
countBy(entries, key) {
|
|
const counts = {};
|
|
for (const entry of entries) {
|
|
const val = entry[key] || 'unknown';
|
|
counts[val] = (counts[val] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
countDisproven(goldEntries, date) {
|
|
// Räkna hur många beslut som senare visade sig felaktiga
|
|
let disproven = 0;
|
|
for (const entry of goldEntries) {
|
|
const predictions = entry.predictions || [];
|
|
const incorrect = predictions.filter(p => p.verified === false).length;
|
|
disproven += incorrect;
|
|
}
|
|
return disproven;
|
|
}
|
|
|
|
calculateAvgStability(entries) {
|
|
if (entries.length === 0) return 'N/A';
|
|
const scores = entries
|
|
.map(e => parseFloat(e.stability?.stabilityScore))
|
|
.filter(s => !isNaN(s));
|
|
if (scores.length === 0) return 'N/A';
|
|
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) + '%';
|
|
}
|
|
|
|
getWeekKey(date) {
|
|
const d = new Date(date);
|
|
d.setHours(0, 0, 0, 0);
|
|
d.setDate(d.getDate() - d.getDay() + 1); // Måndag
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
// ── Output ──────────────────────────────────────────────────────────────
|
|
|
|
saveDailyReport(report) {
|
|
const filename = `${DASHBOARD_DIR}/daily-${report.date}.json`;
|
|
writeFileSync(filename, JSON.stringify(report, null, 2));
|
|
}
|
|
|
|
printDailyReport(report) {
|
|
console.log('╔═══════════════════════════════════════════════════════════════╗');
|
|
console.log('║ SIL OBSERVABILITY DASHBOARD ║');
|
|
console.log(`║ ${report.date} ║`);
|
|
console.log('╚═══════════════════════════════════════════════════════════════╝');
|
|
console.log();
|
|
|
|
// Beslut
|
|
console.log('📊 BESLUT IDAG');
|
|
console.log();
|
|
console.log(` Totala analyser: ${report.decisions.total}`);
|
|
console.log(` Senare motbevisade: ${report.decisions.laterDisproven}`);
|
|
console.log();
|
|
|
|
// Osäkerhet
|
|
console.log('❓ OSÄKERHET');
|
|
console.log();
|
|
console.log(` UNKNOWN: ${report.uncertainty.unknown}`);
|
|
console.log(` UNCERTAIN: ${report.uncertainty.uncertain}`);
|
|
console.log(` CERTAIN: ${report.uncertainty.certain}`);
|
|
console.log();
|
|
|
|
// Graf
|
|
console.log('🕸️ GRAF-HÄLSA');
|
|
console.log();
|
|
console.log(` Versioner: ${report.graph.versions}`);
|
|
console.log(` Senaste: ${report.graph.latestVersion}`);
|
|
console.log(` Noder: ${report.graph.nodes}`);
|
|
console.log(` Kanter: ${report.graph.edges}`);
|
|
console.log();
|
|
|
|
// Stabilitet
|
|
console.log('🔄 STABILITET');
|
|
console.log();
|
|
console.log(` Tester körda: ${report.stability.testsRun}`);
|
|
console.log(` Deterministiska: ${report.stability.deterministic}`);
|
|
console.log(` Genomsnitt: ${report.stability.avgStability}`);
|
|
console.log();
|
|
|
|
// Webhooks
|
|
console.log('🌐 WEBHOOKS');
|
|
console.log();
|
|
console.log(` Events: ${report.webhooks.events}`);
|
|
console.log(` Köade: ${report.webhooks.queued}`);
|
|
console.log(` Klara: ${report.webhooks.completed}`);
|
|
console.log();
|
|
|
|
// Regelanvändning
|
|
const rules = this.analyzeRuleUsage();
|
|
console.log('📋 REGELANVÄNDNING');
|
|
console.log();
|
|
console.log(` Utilization: ${rules.utilizationRate}`);
|
|
console.log(` Oanvända: ${rules.unused.join(', ') || 'none'}`);
|
|
console.log();
|
|
|
|
// Confidence
|
|
const conf = this.analyzeConfidence();
|
|
console.log('🎯 CONFIDENCE-FÖRDELNING');
|
|
console.log();
|
|
for (const [range, count] of Object.entries(conf)) {
|
|
console.log(` ${range}: ${count}`);
|
|
}
|
|
console.log();
|
|
|
|
// Trend
|
|
if (report.quality) {
|
|
console.log('📈 KVALITETSTREND (veckovis)');
|
|
console.log();
|
|
for (const week of report.quality.slice(-4)) {
|
|
console.log(` ${week.week}: recall=${week.recall} precision=${week.precision} (${week.prs} PR:er)`);
|
|
}
|
|
console.log();
|
|
}
|
|
|
|
console.log('═══════════════════════════════════════════════════════════════');
|
|
console.log();
|
|
}
|
|
|
|
// ── HTML Dashboard ──────────────────────────────────────────────────────
|
|
|
|
generateHTMLDashboard() {
|
|
const report = this.generateDailyReport();
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>SIL Dashboard — ${report.date}</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; margin: 40px; background: #f5f5f5; }
|
|
.card { background: white; border-radius: 8px; padding: 20px; margin: 20px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
.metric { display: inline-block; margin: 10px 20px; }
|
|
.metric-value { font-size: 32px; font-weight: bold; color: #333; }
|
|
.metric-label { font-size: 14px; color: #666; }
|
|
.good { color: #22c55e; }
|
|
.warning { color: #f59e0b; }
|
|
.bad { color: #ef4444; }
|
|
h1 { color: #333; }
|
|
h2 { color: #555; border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #eee; }
|
|
th { color: #666; font-weight: 500; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🔍 SIL Observability Dashboard</h1>
|
|
<p>${report.date} — Genererad ${report.generatedAt}</p>
|
|
|
|
<div class="card">
|
|
<h2>📊 Beslut idag</h2>
|
|
<div class="metric">
|
|
<div class="metric-value">${report.decisions.total}</div>
|
|
<div class="metric-label">Analyser</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-value ${report.decisions.laterDisproven > 0 ? 'bad' : 'good'}">${report.decisions.laterDisproven}</div>
|
|
<div class="metric-label">Motbevisade</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>❓ Osäkerhet</h2>
|
|
<div class="metric">
|
|
<div class="metric-value">${report.uncertainty.unknown}</div>
|
|
<div class="metric-label">UNKNOWN</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-value">${report.uncertainty.uncertain}</div>
|
|
<div class="metric-label">UNCERTAIN</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-value">${report.uncertainty.certain}</div>
|
|
<div class="metric-label">CERTAIN</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>🕸️ Graf-hälsa</h2>
|
|
<p>Version: <strong>${report.graph.latestVersion}</strong></p>
|
|
<p>Noder: ${report.graph.nodes} | Kanter: ${report.graph.edges}</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>📈 Kvalitetstrend</h2>
|
|
<table>
|
|
<tr><th>Vecka</th><th>PR:er</th><th>Recall</th><th>Precision</th><th>FNR</th></tr>
|
|
${(report.quality || []).map(w => `
|
|
<tr>
|
|
<td>${w.week}</td>
|
|
<td>${w.prs}</td>
|
|
<td>${w.recall}</td>
|
|
<td>${w.precision}</td>
|
|
<td>${w.fnr}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</table>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
const filename = `${DASHBOARD_DIR}/index.html`;
|
|
writeFileSync(filename, html);
|
|
console.log(`💾 HTML-dashboard sparad: ${filename}`);
|
|
}
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────
|
|
|
|
const obs = new SILObservability();
|
|
const command = process.argv[2] || '--daily';
|
|
|
|
if (command === '--daily') {
|
|
obs.generateDailyReport();
|
|
} else if (command === '--html') {
|
|
obs.generateHTMLDashboard();
|
|
} else if (command === '--rules') {
|
|
const rules = obs.analyzeRuleUsage();
|
|
console.log(JSON.stringify(rules, null, 2));
|
|
} else {
|
|
console.log('Användning:');
|
|
console.log(' node observability.mjs --daily # Daglig rapport');
|
|
console.log(' node observability.mjs --html # Generera HTML-dashboard');
|
|
console.log(' node observability.mjs --rules # Analysera regelanvändning');
|
|
}
|