@logboard/cli 1.0.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/.env.example +37 -0
- package/README.md +200 -0
- package/bin/logboard +536 -0
- package/client/logger.js +309 -0
- package/config/index.js +142 -0
- package/config.js +2 -0
- package/controllers/AnalyticsController.js +46 -0
- package/controllers/ApiAnalyticsController.js +129 -0
- package/controllers/ApiKeyController.js +58 -0
- package/controllers/AuthController.js +131 -0
- package/controllers/HealthController.js +56 -0
- package/controllers/LogController.js +197 -0
- package/controllers/OrgController.js +152 -0
- package/controllers/RoleConfigController.js +20 -0
- package/controllers/SettingsController.js +39 -0
- package/controllers/StreamController.js +55 -0
- package/controllers/UiController.js +789 -0
- package/controllers/UserController.js +79 -0
- package/lib/batchWriter.js +57 -0
- package/lib/cleanup.js +67 -0
- package/lib/ejs.js +103 -0
- package/lib/emitter.js +5 -0
- package/lib/healthMonitor.js +245 -0
- package/lib/logger.js +21 -0
- package/lib/streams.js +32 -0
- package/lib/theme.js +77 -0
- package/lib/userStore.js +13 -0
- package/lib/utils.js +44 -0
- package/middleware/apiKey.js +82 -0
- package/middleware/auth.js +55 -0
- package/middleware/ipWhitelist.js +59 -0
- package/middleware/org.js +85 -0
- package/middleware/pageAccess.js +20 -0
- package/middleware/rateLimit.js +29 -0
- package/middleware/roles.js +11 -0
- package/package.json +77 -0
- package/routes/alerts.js +18 -0
- package/routes/analytics.js +26 -0
- package/routes/api-analytics.js +30 -0
- package/routes/api-keys.js +12 -0
- package/routes/archive.js +91 -0
- package/routes/audit.js +50 -0
- package/routes/auth.js +22 -0
- package/routes/bookmarks.js +13 -0
- package/routes/health.js +11 -0
- package/routes/logs.js +88 -0
- package/routes/metrics.js +66 -0
- package/routes/notifications.js +14 -0
- package/routes/orgs.js +98 -0
- package/routes/registration.js +202 -0
- package/routes/role-config.js +97 -0
- package/routes/saved-searches.js +12 -0
- package/routes/server.js +151 -0
- package/routes/settings.js +28 -0
- package/routes/status.js +21 -0
- package/routes/stream.js +11 -0
- package/routes/super.js +129 -0
- package/routes/ui.js +120 -0
- package/routes/users.js +13 -0
- package/server.js +172 -0
- package/services/AlertRulesService.js +323 -0
- package/services/AnalyticsService.js +665 -0
- package/services/ApiAnalyticsService.js +471 -0
- package/services/ApiKeyService.js +166 -0
- package/services/AuditService.js +249 -0
- package/services/AuthService.js +234 -0
- package/services/BookmarkService.js +49 -0
- package/services/GlobalSettingsService.js +44 -0
- package/services/LogService.js +1066 -0
- package/services/MetricsService.js +116 -0
- package/services/NotificationService.js +70 -0
- package/services/OrgService.js +217 -0
- package/services/ReportService.js +247 -0
- package/services/RoleConfigService.js +201 -0
- package/services/SavedSearchService.js +63 -0
- package/services/SettingsService.js +220 -0
- package/services/UserService.js +121 -0
- package/setup.js +132 -0
- package/views/404.ejs +8 -0
- package/views/alerts.ejs +190 -0
- package/views/analytics.ejs +209 -0
- package/views/api-analytics.ejs +660 -0
- package/views/api-keys.ejs +150 -0
- package/views/archive.ejs +123 -0
- package/views/audit.ejs +314 -0
- package/views/bookmarks.ejs +54 -0
- package/views/custom-dashboard.ejs +162 -0
- package/views/dashboard.ejs +186 -0
- package/views/diff.ejs +98 -0
- package/views/health.ejs +269 -0
- package/views/heatmap.ejs +126 -0
- package/views/insights.ejs +334 -0
- package/views/invite.ejs +74 -0
- package/views/live.ejs +299 -0
- package/views/login.ejs +64 -0
- package/views/logo.png +0 -0
- package/views/logs.ejs +754 -0
- package/views/notifications.ejs +58 -0
- package/views/partials/head.ejs +282 -0
- package/views/partials/sidebar.ejs +168 -0
- package/views/register.ejs +100 -0
- package/views/roles.ejs +279 -0
- package/views/saved-searches.ejs +51 -0
- package/views/service-map.ejs +142 -0
- package/views/settings.ejs +1159 -0
- package/views/sidebar.ejs +129 -0
- package/views/status.ejs +100 -0
- package/views/super-admin-admins.ejs +58 -0
- package/views/super-admin-analytics.ejs +49 -0
- package/views/super-admin-orgs.ejs +310 -0
- package/views/super-admin-profile.ejs +77 -0
- package/views/super-admin-settings.ejs +108 -0
- package/views/super-admin-system.ejs +46 -0
- package/views/users.ejs +153 -0
package/views/health.ejs
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
<%- include('partials/head', {title:'Health'}) %>
|
|
2
|
+
<style>
|
|
3
|
+
.svc-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px;}
|
|
4
|
+
.svc-tab{padding:5px 14px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--text2);transition:all .15s;}
|
|
5
|
+
.svc-tab.active{background:var(--accent);border-color:var(--accent);color:#fff;}
|
|
6
|
+
.metric-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:16px;}
|
|
7
|
+
.metric-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px 16px;}
|
|
8
|
+
.metric-val{font-size:24px;font-weight:500;line-height:1.1;margin-bottom:2px;}
|
|
9
|
+
.metric-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.4px;margin-bottom:6px;}
|
|
10
|
+
.metric-bar{height:4px;background:var(--border);border-radius:2px;overflow:hidden;}
|
|
11
|
+
.metric-bar-fill{height:100%;border-radius:2px;transition:width .3s;}
|
|
12
|
+
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;}
|
|
13
|
+
.chart-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;}
|
|
14
|
+
.chart-title{font-size:12px;font-weight:500;color:var(--text2);margin-bottom:10px;display:flex;align-items:center;gap:6px;}
|
|
15
|
+
.chart-wrap{position:relative;height:140px;}
|
|
16
|
+
.range-btns{display:flex;gap:4px;margin-left:auto;}
|
|
17
|
+
.range-btn{padding:2px 8px;font-size:11px;border-radius:4px;border:1px solid var(--border);background:none;color:var(--text3);cursor:pointer;}
|
|
18
|
+
.range-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent-l);}
|
|
19
|
+
.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px;}
|
|
20
|
+
.status-ok{background:#22c55e;} .status-warn{background:#f59e0b;} .status-err{background:#ef4444;}
|
|
21
|
+
.no-metrics{text-align:center;padding:60px 20px;color:var(--text3);}
|
|
22
|
+
</style>
|
|
23
|
+
<div class="app-shell">
|
|
24
|
+
<%- include('partials/sidebar') %>
|
|
25
|
+
<div class="main-area">
|
|
26
|
+
<header class="top-header">
|
|
27
|
+
<div class="page-title">
|
|
28
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px;vertical-align:-2px"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
|
29
|
+
Service Health
|
|
30
|
+
</div>
|
|
31
|
+
<div style="display:flex;align-items:center;gap:8px;">
|
|
32
|
+
<div class="range-btns" id="range-btns">
|
|
33
|
+
<button class="range-btn active" data-range="60">1h</button>
|
|
34
|
+
<button class="range-btn" data-range="360">6h</button>
|
|
35
|
+
<button class="range-btn active-def" data-range="1440">24h</button>
|
|
36
|
+
<button class="range-btn" data-range="10080">7d</button>
|
|
37
|
+
</div>
|
|
38
|
+
<button class="btn btn-secondary btn-sm" onclick="refresh()">Refresh</button>
|
|
39
|
+
</div>
|
|
40
|
+
</header>
|
|
41
|
+
<div class="page-content">
|
|
42
|
+
|
|
43
|
+
<!-- Service tabs -->
|
|
44
|
+
<div class="svc-tabs" id="svc-tabs">
|
|
45
|
+
<div style="font-size:12px;color:var(--text3);">Loading services…</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Metric status cards -->
|
|
49
|
+
<div class="metric-grid" id="metric-cards">
|
|
50
|
+
<% for(let i=0;i<6;i++){ %>
|
|
51
|
+
<div class="metric-card" style="opacity:.3;animation:pulse 1s infinite alternate;">
|
|
52
|
+
<div class="metric-label">—</div><div class="metric-val">—</div>
|
|
53
|
+
</div>
|
|
54
|
+
<% } %>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<!-- Charts -->
|
|
58
|
+
<div class="chart-grid" id="charts-area">
|
|
59
|
+
<div class="chart-card"><div class="chart-title">Loading charts…</div></div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Per-service log stats (always available) -->
|
|
63
|
+
<div class="card" id="log-stats-card">
|
|
64
|
+
<div class="card-title" style="margin-bottom:12px;">
|
|
65
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:6px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
|
|
66
|
+
Log stats for <span id="log-stats-title" style="color:var(--accent-l);">—</span>
|
|
67
|
+
</div>
|
|
68
|
+
<div id="log-stats-body" style="font-size:12px;color:var(--text2);">Select a service above</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
76
|
+
<script>
|
|
77
|
+
const tc = getComputedStyle(document.documentElement).getPropertyValue('--text2').trim() || '#888';
|
|
78
|
+
const gc = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#333';
|
|
79
|
+
const CHART_OPT = {responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{mode:'index',intersect:false}},scales:{x:{ticks:{color:tc,font:{size:9},maxTicksLimit:8},grid:{color:gc}},y:{ticks:{color:tc,font:{size:9}},grid:{color:gc}}}};
|
|
80
|
+
|
|
81
|
+
let curService = null, curRange = 60, allSvcs = [], charts = {}, serviceHealth = <%- JSON.stringify(typeof serviceHealth!=='undefined'?serviceHealth:[]) %>;
|
|
82
|
+
|
|
83
|
+
// ── Init ─────────────────────────────────────────────────────────────────
|
|
84
|
+
async function init() {
|
|
85
|
+
// Get services from metrics + log analytics
|
|
86
|
+
const r = await fetch('/api/metrics/services', {credentials:'include'});
|
|
87
|
+
const d = await r.json().catch(()=>({services:[]}));
|
|
88
|
+
const metricSvcs = d.services || [];
|
|
89
|
+
const logSvcs = serviceHealth.map(s=>s.appName);
|
|
90
|
+
allSvcs = [...new Set([...metricSvcs, ...logSvcs])];
|
|
91
|
+
|
|
92
|
+
renderTabs();
|
|
93
|
+
if (allSvcs.length) selectService(allSvcs[0]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderTabs() {
|
|
97
|
+
const el = document.getElementById('svc-tabs');
|
|
98
|
+
if (!allSvcs.length) { el.innerHTML = '<div class="no-metrics">No services found yet. Start ingesting logs or enable metrics in your SDK.</div>'; return; }
|
|
99
|
+
el.innerHTML = allSvcs.map(s => `<div class="svc-tab${s===curService?' active':''}" onclick="selectService('${s}')">${s}</div>`).join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function selectService(name) {
|
|
103
|
+
curService = name;
|
|
104
|
+
renderTabs();
|
|
105
|
+
document.getElementById('log-stats-title').textContent = name;
|
|
106
|
+
await Promise.all([loadMetrics(name), loadLogStats(name)]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Metrics charts ────────────────────────────────────────────────────────
|
|
110
|
+
async function loadMetrics(name) {
|
|
111
|
+
const r = await fetch(`/api/metrics/${encodeURIComponent(name)}?range=${curRange}`, {credentials:'include'});
|
|
112
|
+
const d = await r.json().catch(()=>({rows:[]}));
|
|
113
|
+
const rows = d.rows || [];
|
|
114
|
+
|
|
115
|
+
if (!rows.length) {
|
|
116
|
+
// Fallback: try to extract metrics from log entries (old SDK sends as system_metrics type)
|
|
117
|
+
await loadMetricsFromLogs(name);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Latest snapshot for stat cards
|
|
122
|
+
const latest = rows[rows.length - 1];
|
|
123
|
+
renderStatCards(latest);
|
|
124
|
+
renderCharts(rows);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderStatCards(s) {
|
|
128
|
+
const cards = [
|
|
129
|
+
{ label:'CPU', val: s.cpuPct != null ? Math.round(s.cpuPct)+'%' : '—', pct: s.cpuPct, warn:70, crit:90 },
|
|
130
|
+
{ label:'RAM', val: s.ramPct != null ? Math.round(s.ramPct)+'%' : '—', pct: s.ramPct, warn:75, crit:90, sub: s.ramUsedMB ? Math.round(s.ramUsedMB)+'MB / '+Math.round(s.ramTotalMB)+'MB' : '' },
|
|
131
|
+
{ label:'Heap', val: s.heapPct != null ? Math.round(s.heapPct)+'%' : '—', pct: s.heapPct, warn:70, crit:85, sub: s.heapUsedMB ? Math.round(s.heapUsedMB)+'MB / '+Math.round(s.heapTotalMB)+'MB' : '' },
|
|
132
|
+
{ label:'RSS', val: s.rssMB != null ? Math.round(s.rssMB)+'MB' : '—', pct: null },
|
|
133
|
+
{ label:'Uptime', val: s.uptimeSec != null ? fmtUptime(s.uptimeSec) : '—', pct: null },
|
|
134
|
+
{ label:'Event Loop', val: s.eventLoopMs != null ? Math.round(s.eventLoopMs)+'ms' : '—', pct: s.eventLoopMs ? Math.min(100, s.eventLoopMs/10) : null, warn:50, crit:100 },
|
|
135
|
+
];
|
|
136
|
+
document.getElementById('metric-cards').innerHTML = cards.map(c => {
|
|
137
|
+
const color = c.pct != null ? (c.pct > (c.crit||90) ? 'var(--red)' : c.pct > (c.warn||70) ? 'var(--yellow)' : 'var(--green)') : 'var(--text)';
|
|
138
|
+
return `<div class="metric-card">
|
|
139
|
+
<div class="metric-label">${c.label}</div>
|
|
140
|
+
<div class="metric-val" style="color:${color}">${c.val}</div>
|
|
141
|
+
${c.sub ? `<div style="font-size:10px;color:var(--text3);margin-bottom:4px;">${c.sub}</div>` : ''}
|
|
142
|
+
${c.pct != null ? `<div class="metric-bar"><div class="metric-bar-fill" style="width:${Math.min(100,Math.round(c.pct))}%;background:${color};"></div></div>` : ''}
|
|
143
|
+
</div>`;
|
|
144
|
+
}).join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderCharts(rows) {
|
|
148
|
+
const labels = rows.map(r => {
|
|
149
|
+
const d = new Date(r.ts);
|
|
150
|
+
return curRange <= 360 ? d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) : d.toLocaleDateString([],{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const defs = [
|
|
154
|
+
{ id:'cpu-chart', title:'CPU usage %', key:'cpuPct', color:'rgba(59,130,246,.8)', yMax:100 },
|
|
155
|
+
{ id:'ram-chart', title:'RAM + Heap %', key:'ramPct', color:'rgba(16,185,129,.8)', extra:{key:'heapPct',color:'rgba(139,92,246,.6)'}, yMax:100 },
|
|
156
|
+
{ id:'heap-chart', title:'Heap used (MB)', key:'heapUsedMB', color:'rgba(139,92,246,.8)' },
|
|
157
|
+
{ id:'event-chart', title:'Event loop lag (ms)',key:'eventLoopMs', color:'rgba(245,158,11,.8)' },
|
|
158
|
+
{ id:'rss-chart', title:'RSS memory (MB)', key:'rssMB', color:'rgba(107,114,128,.6)' },
|
|
159
|
+
{ id:'disk-chart', title:'Disk used %', key:'diskPct', color:'rgba(239,68,68,.7)', yMax:100 },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
document.getElementById('charts-area').innerHTML = defs.map(d =>
|
|
163
|
+
`<div class="chart-card"><div class="chart-title">${d.title}</div><div class="chart-wrap"><canvas id="${d.id}"></canvas></div></div>`
|
|
164
|
+
).join('');
|
|
165
|
+
|
|
166
|
+
defs.forEach(def => {
|
|
167
|
+
const ctx = document.getElementById(def.id);
|
|
168
|
+
if (!ctx) return;
|
|
169
|
+
if (charts[def.id]) { charts[def.id].destroy(); }
|
|
170
|
+
const datasets = [{ label: def.title, data: rows.map(r=>r[def.key]!=null?Math.round(r[def.key]*10)/10:null), borderColor: def.color, backgroundColor: def.color.replace('.8',',.15').replace('.7',',.15').replace('.6',',.12'), fill:true, tension:.3, pointRadius:0, spanGaps:true }];
|
|
171
|
+
if (def.extra) datasets.push({ label: def.extra.key, data: rows.map(r=>r[def.extra.key]!=null?Math.round(r[def.extra.key]*10)/10:null), borderColor: def.extra.color, backgroundColor: 'transparent', tension:.3, pointRadius:0, spanGaps:true });
|
|
172
|
+
const opts = JSON.parse(JSON.stringify(CHART_OPT));
|
|
173
|
+
if (def.yMax) { opts.scales.y.min=0; opts.scales.y.max=def.yMax; }
|
|
174
|
+
charts[def.id] = new Chart(ctx, { type:'line', data:{ labels, datasets }, options:opts });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Log stats (always available even without SDK metrics) ─────────────────
|
|
179
|
+
async function loadLogStats(name) {
|
|
180
|
+
const svcData = serviceHealth.find(s=>s.appName===name);
|
|
181
|
+
if (!svcData) { document.getElementById('log-stats-body').innerHTML = '<span style="color:var(--text3);">No log data found for this service today.</span>'; return; }
|
|
182
|
+
const er = svcData.errorRate || 0;
|
|
183
|
+
const status = er > 10 ? 'status-err' : er > 3 ? 'status-warn' : 'status-ok';
|
|
184
|
+
document.getElementById('log-stats-body').innerHTML = `
|
|
185
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;">
|
|
186
|
+
<div><div style="font-size:10px;color:var(--text3);margin-bottom:3px;">LOGS TODAY</div><div style="font-size:20px;font-weight:500;">${(svcData.today||0).toLocaleString()}</div></div>
|
|
187
|
+
<div><div style="font-size:10px;color:var(--text3);margin-bottom:3px;">ERRORS</div><div style="font-size:20px;font-weight:500;color:var(--red)">${(svcData.errors||0).toLocaleString()}</div></div>
|
|
188
|
+
<div><div style="font-size:10px;color:var(--text3);margin-bottom:3px;">ERROR RATE</div><div style="font-size:20px;font-weight:500;"><span class="status-dot ${status}"></span>${er}%</div></div>
|
|
189
|
+
<div><div style="font-size:10px;color:var(--text3);margin-bottom:3px;">TOTAL LOGS</div><div style="font-size:20px;font-weight:500;">${(svcData.total||0).toLocaleString()}</div></div>
|
|
190
|
+
</div>
|
|
191
|
+
<div style="margin-top:10px;"><a href="/logs?service=${encodeURIComponent(name)}" class="btn btn-secondary btn-xs">View logs →</a> <a href="/logs?service=${encodeURIComponent(name)}&level=error" class="btn btn-secondary btn-xs" style="margin-left:6px;">View errors →</a></div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function loadMetricsFromLogs(name) {
|
|
195
|
+
try {
|
|
196
|
+
// Fetch recent logs for this service and extract system_metrics entries
|
|
197
|
+
const r = await fetch(`/api/logs/search?service=${encodeURIComponent(name)}&limit=200&q=system_metrics`, {credentials:'include'});
|
|
198
|
+
if (!r.ok) throw new Error('search failed');
|
|
199
|
+
const d = await r.json();
|
|
200
|
+
const metricLogs = (d.results||d.lines||[]).filter(l => l.type==='system_metrics' || (l.message&&l.message.includes('System metrics')));
|
|
201
|
+
|
|
202
|
+
if (metricLogs.length > 0) {
|
|
203
|
+
// Convert log entries to metrics rows
|
|
204
|
+
const rows = metricLogs.map(l => ({
|
|
205
|
+
ts: l.ts,
|
|
206
|
+
cpuPct: l.cpu?.usedPct,
|
|
207
|
+
ramPct: l.ram?.usedPct,
|
|
208
|
+
ramUsedMB: l.ram?.usedMB,
|
|
209
|
+
ramTotalMB: l.ram?.totalMB,
|
|
210
|
+
diskPct: l.disk?.usedPct,
|
|
211
|
+
heapUsedMB: l.process?.heapUsedMB,
|
|
212
|
+
heapTotalMB: l.process?.heapTotalMB,
|
|
213
|
+
heapPct: l.process?.heapUsedMB && l.process?.heapTotalMB ? Math.round(l.process.heapUsedMB/l.process.heapTotalMB*100) : null,
|
|
214
|
+
rssMB: l.process?.rssMB,
|
|
215
|
+
uptimeSec: l.process?.uptime,
|
|
216
|
+
nodeVersion: l.process?.version,
|
|
217
|
+
platform: l.process?.platform,
|
|
218
|
+
hostname: l.hostname,
|
|
219
|
+
eventLoopMs: null,
|
|
220
|
+
})).filter(r=>r.cpuPct!=null);
|
|
221
|
+
|
|
222
|
+
if (rows.length > 0) {
|
|
223
|
+
renderStatCards(rows[rows.length-1]);
|
|
224
|
+
renderCharts(rows);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch(e) {}
|
|
229
|
+
// No metrics at all - show setup hint
|
|
230
|
+
document.getElementById('metric-cards').innerHTML = `<div style="grid-column:1/-1;padding:24px;text-align:center;color:var(--text3);font-size:13px;">No metrics yet for <strong>${name}</strong>. Add <code>metrics: true</code> to your Logger config.</div>`;
|
|
231
|
+
document.getElementById('charts-area').innerHTML = '';
|
|
232
|
+
renderSdkHint(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderSdkHint(name) {
|
|
236
|
+
document.getElementById('charts-area').innerHTML = `
|
|
237
|
+
<div class="chart-card" style="grid-column:1/-1;">
|
|
238
|
+
<div style="padding:16px 0;font-size:12px;line-height:1.8;">
|
|
239
|
+
<div style="font-weight:500;margin-bottom:10px;">Enable system metrics in your service</div>
|
|
240
|
+
<div style="background:var(--surface2);border-radius:6px;padding:12px 14px;font-family:monospace;font-size:11.5px;color:var(--text2);">
|
|
241
|
+
<div style="color:var(--text3);">// In your ${name} app:</div>
|
|
242
|
+
const logger = new Logger({<br>
|
|
243
|
+
apiKey: 'your-api-key',<br>
|
|
244
|
+
appName: '${name}',<br>
|
|
245
|
+
<span style="color:var(--green);">metrics: true,</span><br>
|
|
246
|
+
<span style="color:var(--green);">metricsInterval: 30000,</span> <span style="color:var(--text3);">// 30s</span><br>
|
|
247
|
+
});
|
|
248
|
+
</div>
|
|
249
|
+
<div style="margin-top:10px;color:var(--text3);font-size:11px;">Collects: CPU %, RAM %, Heap %, RSS, Disk %, Event loop lag — all automatic.</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Range selector ────────────────────────────────────────────────────────
|
|
255
|
+
document.querySelectorAll('.range-btn').forEach(btn => {
|
|
256
|
+
btn.addEventListener('click', () => {
|
|
257
|
+
document.querySelectorAll('.range-btn').forEach(b=>b.classList.remove('active'));
|
|
258
|
+
btn.classList.add('active');
|
|
259
|
+
curRange = parseInt(btn.dataset.range);
|
|
260
|
+
if (curService) loadMetrics(curService);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
function refresh() { if (curService) { loadMetrics(curService); loadLogStats(curService); } }
|
|
265
|
+
function fmtUptime(s) { const h=Math.floor(s/3600),m=Math.floor((s%3600)/60); return h>24?Math.floor(h/24)+'d '+h%24+'h':h+'h '+m+'m'; }
|
|
266
|
+
|
|
267
|
+
init();
|
|
268
|
+
</script>
|
|
269
|
+
</body></html>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<%- include('partials/head', { title: 'Error Heatmap' }) %>
|
|
2
|
+
<div class="app-shell">
|
|
3
|
+
<%- include('partials/sidebar') %>
|
|
4
|
+
<div class="main-area">
|
|
5
|
+
<header class="top-header">
|
|
6
|
+
<div class="page-title">Error Heatmap</div>
|
|
7
|
+
<div class="header-actions">
|
|
8
|
+
<select class="form-select" id="svc-sel" style="width:200px;" onchange="location='?service='+this.value">
|
|
9
|
+
<option value="">— Select Service —</option>
|
|
10
|
+
<% services.forEach(function(s){ %>
|
|
11
|
+
<option value="<%= s.appName %>" <%= service===s.appName?'selected':'' %>><%= s.appName %></option>
|
|
12
|
+
<% }) %>
|
|
13
|
+
</select>
|
|
14
|
+
</div>
|
|
15
|
+
</header>
|
|
16
|
+
<div class="page-content">
|
|
17
|
+
<% if (!service) { %>
|
|
18
|
+
<div class="card"><div class="empty-state" style="padding:50px 0;">
|
|
19
|
+
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
|
20
|
+
<p>Select a service to view its error heatmap</p>
|
|
21
|
+
</div></div>
|
|
22
|
+
<% } else if (!heatmapData || !heatmapData.days || !heatmapData.days.length) { %>
|
|
23
|
+
<div class="card"><div class="empty-state" style="padding:50px 0;">
|
|
24
|
+
<p>No data available for <strong><%= service %></strong></p>
|
|
25
|
+
</div></div>
|
|
26
|
+
<% } else { %>
|
|
27
|
+
|
|
28
|
+
<!-- Summary cards -->
|
|
29
|
+
<div class="stats-grid" style="margin-bottom:14px;">
|
|
30
|
+
<div class="stat-card">
|
|
31
|
+
<div class="stat-icon red"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
|
32
|
+
<div><div class="stat-value"><%= heatmapData.totalErrors.toLocaleString() %></div><div class="stat-label">Total Errors (90d)</div></div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="stat-card">
|
|
35
|
+
<div class="stat-icon yellow"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="10" x2="21" y2="10"/></svg></div>
|
|
36
|
+
<div><div class="stat-value"><%= heatmapData.daysWithErrors %></div><div class="stat-label">Days with Errors</div></div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="stat-card">
|
|
39
|
+
<div class="stat-icon red"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></div>
|
|
40
|
+
<div><div class="stat-value"><%= heatmapData.worstDay ? heatmapData.worstDay.errors : 0 %></div><div class="stat-label">Worst Day</div><% if(heatmapData.worstDay){%><div class="stat-sub"><%= heatmapData.worstDay.date %></div><%}%></div>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="stat-card">
|
|
43
|
+
<div class="stat-icon green"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,7 13.5,15.5 8.5,10.5 2,17"/><polyline points="16,7 22,7 22,13"/></svg></div>
|
|
44
|
+
<div><div class="stat-value"><%= heatmapData.avgErrorsPerDay.toFixed(1) %></div><div class="stat-label">Avg Errors/Day</div></div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Heatmap -->
|
|
49
|
+
<div class="card">
|
|
50
|
+
<div style="font-weight:600;font-size:13px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;">
|
|
51
|
+
<span>Error Density — Last 90 Days</span>
|
|
52
|
+
<div style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text3);">
|
|
53
|
+
Less
|
|
54
|
+
<% [[0,'var(--surface2)'],[1,'rgba(239,68,68,.2)'],[2,'rgba(239,68,68,.45)'],[3,'rgba(239,68,68,.7)'],[4,'#ef4444']].forEach(function(c){%>
|
|
55
|
+
<span style="width:13px;height:13px;border-radius:2px;background:<%=c[1]%>;display:inline-block;"></span>
|
|
56
|
+
<%})%>
|
|
57
|
+
More
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- Day labels -->
|
|
62
|
+
<div style="display:flex;gap:2px;margin-bottom:4px;padding-left:30px;">
|
|
63
|
+
<% const months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
64
|
+
var prevMonth=-1;
|
|
65
|
+
heatmapData.days.forEach(function(d,i){
|
|
66
|
+
var m=new Date(d.date).getMonth();
|
|
67
|
+
if(m!==prevMonth){prevMonth=m; %>
|
|
68
|
+
<span style="font-size:9px;color:var(--text3);min-width:13px;"><%=months[m]%></span>
|
|
69
|
+
<% }else{ %><span style="min-width:13px;"></span><% } }) %>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div style="display:flex;gap:4px;">
|
|
73
|
+
<!-- Week day labels -->
|
|
74
|
+
<div style="display:flex;flex-direction:column;gap:2px;width:26px;flex-shrink:0;">
|
|
75
|
+
<% ['','Mon','','Wed','','Fri',''].forEach(function(d){%>
|
|
76
|
+
<span style="font-size:9px;color:var(--text3);height:13px;line-height:13px;"><%=d%></span>
|
|
77
|
+
<%})%>
|
|
78
|
+
</div>
|
|
79
|
+
<!-- Calendar grid — columns=weeks, rows=weekdays -->
|
|
80
|
+
<div style="display:flex;gap:2px;" id="heatmap-grid">
|
|
81
|
+
<script>
|
|
82
|
+
const _days = <%- JSON.stringify(heatmapData.days) %>;
|
|
83
|
+
const _max = Math.max(..._days.map(d=>d.errors), 1);
|
|
84
|
+
// Group by week
|
|
85
|
+
const weeks = [];
|
|
86
|
+
let week = [];
|
|
87
|
+
// Pad start
|
|
88
|
+
const firstDay = new Date(_days[0].date).getDay(); // 0=Sun
|
|
89
|
+
const pad = firstDay === 0 ? 6 : firstDay - 1; // Mon-start
|
|
90
|
+
for (let i=0;i<pad;i++) week.push(null);
|
|
91
|
+
_days.forEach(function(d){
|
|
92
|
+
week.push(d);
|
|
93
|
+
if(week.length===7){weeks.push(week);week=[];}
|
|
94
|
+
});
|
|
95
|
+
if(week.length) weeks.push(week);
|
|
96
|
+
|
|
97
|
+
weeks.forEach(function(w){
|
|
98
|
+
const col = document.createElement('div');
|
|
99
|
+
col.style.cssText='display:flex;flex-direction:column;gap:2px;';
|
|
100
|
+
for(let i=0;i<7;i++){
|
|
101
|
+
const cell = document.createElement('div');
|
|
102
|
+
cell.style.cssText='width:13px;height:13px;border-radius:2px;';
|
|
103
|
+
const d=w[i];
|
|
104
|
+
if(!d){cell.style.background='transparent';}
|
|
105
|
+
else{
|
|
106
|
+
const ratio=d.errors/_max;
|
|
107
|
+
const opacity = d.errors===0 ? 0 : ratio<.25?.2:ratio<.5?.45:ratio<.75?.7:1;
|
|
108
|
+
cell.style.background = d.errors===0 ? 'var(--surface2)' : `rgba(239,68,68,${opacity})`;
|
|
109
|
+
cell.title=d.date+': '+d.errors+' errors'+(d.total?' / '+d.total+' logs':'');
|
|
110
|
+
cell.style.cursor='pointer';
|
|
111
|
+
cell.onclick=(function(date){return function(){location='/logs?service=<%=service%>&date='+date+'&level=error';};})(d.date);
|
|
112
|
+
}
|
|
113
|
+
col.appendChild(cell);
|
|
114
|
+
}
|
|
115
|
+
document.getElementById('heatmap-grid').appendChild(col);
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<% } %>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</body></html>
|