@mantiq/heartbeat 0.5.22 → 0.6.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/package.json +1 -1
- package/src/Heartbeat.ts +15 -27
- package/src/HeartbeatServiceProvider.ts +66 -0
- package/src/contracts/Entry.ts +19 -0
- package/src/contracts/HeartbeatConfig.ts +2 -0
- package/src/dashboard/DashboardController.ts +71 -12
- package/src/dashboard/pages/CachePage.ts +61 -5
- package/src/dashboard/pages/CommandDetailPage.ts +216 -0
- package/src/dashboard/pages/CommandsPage.ts +72 -0
- package/src/dashboard/pages/EventsPage.ts +59 -6
- package/src/dashboard/pages/ExceptionsPage.ts +37 -6
- package/src/dashboard/pages/JobsPage.ts +61 -5
- package/src/dashboard/pages/LogsPage.ts +116 -0
- package/src/dashboard/pages/ModelsPage.ts +112 -0
- package/src/dashboard/pages/NotificationsPage.ts +87 -0
- package/src/dashboard/pages/OverviewPage.ts +109 -45
- package/src/dashboard/pages/PerformancePage.ts +151 -20
- package/src/dashboard/pages/QueriesPage.ts +92 -8
- package/src/dashboard/pages/RequestDetailPage.ts +227 -3
- package/src/dashboard/pages/RequestsPage.ts +90 -3
- package/src/dashboard/pages/SchedulesPage.ts +71 -0
- package/src/dashboard/shared/components.ts +140 -0
- package/src/dashboard/shared/layout.ts +327 -108
- package/src/index.ts +9 -0
- package/src/middleware/HeartbeatMiddleware.ts +26 -9
- package/src/migrations/CreateHeartbeatTables.ts +14 -0
- package/src/models/EntryModel.ts +1 -1
- package/src/storage/HeartbeatStore.ts +174 -1
- package/src/testing/HeartbeatFake.ts +6 -0
- package/src/tracing/Tracer.ts +32 -0
- package/src/watchers/CommandWatcher.ts +23 -0
- package/src/watchers/EventWatcher.ts +13 -0
- package/src/watchers/ScheduleWatcher.ts +1 -0
- package/src/widget/DebugWidget.ts +53 -30
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { renderLayout } from '../shared/layout.ts'
|
|
2
|
+
import { table, badge, timeAgo, escapeHtml, truncate, stat } from '../shared/components.ts'
|
|
3
|
+
import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
|
|
4
|
+
import type { ModelEntryContent } from '../../contracts/Entry.ts'
|
|
5
|
+
|
|
6
|
+
const PER_PAGE = 50
|
|
7
|
+
|
|
8
|
+
const ACTION_VARIANTS: Record<string, 'green' | 'blue' | 'red'> = {
|
|
9
|
+
created: 'green',
|
|
10
|
+
updated: 'blue',
|
|
11
|
+
deleted: 'red',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function originBadge(entry: { origin_type: string }): string {
|
|
15
|
+
const v: Record<string, 'green' | 'blue' | 'amber' | 'mute'> = {
|
|
16
|
+
request: 'green',
|
|
17
|
+
command: 'blue',
|
|
18
|
+
schedule: 'amber',
|
|
19
|
+
job: 'blue',
|
|
20
|
+
}
|
|
21
|
+
return badge(entry.origin_type, v[entry.origin_type] ?? 'mute')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function renderModelsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
|
|
25
|
+
const page = Math.max(1, parseInt(searchParams?.get('page') ?? '1', 10))
|
|
26
|
+
const actionFilter = searchParams?.get('action') ?? ''
|
|
27
|
+
const search = searchParams?.get('q') ?? ''
|
|
28
|
+
|
|
29
|
+
const total = await store.countEntries('model')
|
|
30
|
+
const entries = await store.getEntries({ type: 'model', limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
|
|
31
|
+
|
|
32
|
+
let creates = 0, updates = 0, deletes = 0
|
|
33
|
+
const filtered: typeof entries = []
|
|
34
|
+
for (const e of entries) {
|
|
35
|
+
const c = JSON.parse(e.content) as ModelEntryContent
|
|
36
|
+
if (c.action === 'created') creates++
|
|
37
|
+
else if (c.action === 'updated') updates++
|
|
38
|
+
else if (c.action === 'deleted') deletes++
|
|
39
|
+
|
|
40
|
+
if (actionFilter && c.action !== actionFilter) continue
|
|
41
|
+
if (search && !c.model_class.toLowerCase().includes(search.toLowerCase())) continue
|
|
42
|
+
filtered.push(e)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const rows = filtered.map((entry) => {
|
|
46
|
+
const c = JSON.parse(entry.content) as ModelEntryContent
|
|
47
|
+
const changesPreview = c.changes ? truncate(JSON.stringify(c.changes), 60) : '--'
|
|
48
|
+
return [
|
|
49
|
+
badge(c.action, ACTION_VARIANTS[c.action] ?? 'mute'),
|
|
50
|
+
`<span class="mono sm">${escapeHtml(c.model_class)}</span>`,
|
|
51
|
+
c.key != null ? `<span class="mono sm muted">${escapeHtml(String(c.key))}</span>` : '<span class="dim">--</span>',
|
|
52
|
+
`<span class="mono trunc sm" title="${escapeHtml(changesPreview)}">${escapeHtml(changesPreview)}</span>`,
|
|
53
|
+
originBadge(entry),
|
|
54
|
+
`<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
|
|
55
|
+
]
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const totalPages = Math.ceil(total / PER_PAGE)
|
|
59
|
+
|
|
60
|
+
const actionOptions = ['created', 'updated', 'deleted']
|
|
61
|
+
.map((a) => `<option value="${a}"${actionFilter === a ? ' selected' : ''}>${a}</option>`)
|
|
62
|
+
.join('')
|
|
63
|
+
|
|
64
|
+
const content = `
|
|
65
|
+
<div class="stats">
|
|
66
|
+
${stat('Total Events', total.toString())}
|
|
67
|
+
${stat('Creates', creates.toString())}
|
|
68
|
+
${stat('Updates', updates.toString())}
|
|
69
|
+
${stat('Deletes', deletes.toString())}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="card mb" style="padding:10px 14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
73
|
+
<form method="get" action="${basePath}/models" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex:1">
|
|
74
|
+
<select name="action" style="padding:5px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-2);color:var(--fg-1);font-size:12px">
|
|
75
|
+
<option value="">All actions</option>
|
|
76
|
+
${actionOptions}
|
|
77
|
+
</select>
|
|
78
|
+
<input type="text" name="q" value="${escapeHtml(search)}" placeholder="Search model class\u2026" style="padding:5px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-2);color:var(--fg-1);font-size:12px;flex:1;min-width:160px">
|
|
79
|
+
<button type="submit" style="padding:5px 12px;border-radius:4px;border:1px solid var(--border);background:var(--bg-2);color:var(--fg-1);font-size:12px;cursor:pointer">Filter</button>
|
|
80
|
+
</form>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="card">
|
|
84
|
+
${table(['Action', 'Model Class', 'Key', 'Changes', 'Origin', 'Time'], rows)}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
${pagination(basePath + '/models', page, totalPages, searchParams)}
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
return renderLayout({ title: 'Models', activePage: 'models', basePath, content })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pagination(baseUrl: string, current: number, total: number, searchParams?: URLSearchParams): string {
|
|
94
|
+
if (total <= 1) return ''
|
|
95
|
+
|
|
96
|
+
function buildUrl(p: number): string {
|
|
97
|
+
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
98
|
+
params.set('page', String(p))
|
|
99
|
+
return `${baseUrl}?${params.toString()}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const items: string[] = []
|
|
103
|
+
if (current > 1) {
|
|
104
|
+
items.push(`<a href="${buildUrl(current - 1)}" style="padding:4px 10px;border-radius:4px;border:1px solid var(--border);color:var(--fg-2);text-decoration:none;font-size:12px">← Prev</a>`)
|
|
105
|
+
}
|
|
106
|
+
items.push(`<span class="sm dim">Page ${current} of ${total}</span>`)
|
|
107
|
+
if (current < total) {
|
|
108
|
+
items.push(`<a href="${buildUrl(current + 1)}" style="padding:4px 10px;border-radius:4px;border:1px solid var(--border);color:var(--fg-2);text-decoration:none;font-size:12px">Next →</a>`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
|
|
112
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { renderLayout } from '../shared/layout.ts'
|
|
2
|
+
import { table, badge, timeAgo, escapeHtml, stat } from '../shared/components.ts'
|
|
3
|
+
import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
|
|
4
|
+
|
|
5
|
+
const PER_PAGE = 50
|
|
6
|
+
|
|
7
|
+
interface NotificationContent {
|
|
8
|
+
type: string
|
|
9
|
+
channel: string
|
|
10
|
+
notifiable: string
|
|
11
|
+
status: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function renderNotificationsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
|
|
15
|
+
const page = Math.max(1, parseInt(searchParams?.get('page') ?? '1', 10))
|
|
16
|
+
|
|
17
|
+
// Notifications are stored as 'notification' type entries when @mantiq/notify is installed
|
|
18
|
+
const entries = await store.getEntries({ type: 'notification' as any, limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
|
|
19
|
+
const total = await store.countEntries('notification' as any)
|
|
20
|
+
|
|
21
|
+
if (entries.length === 0) {
|
|
22
|
+
const content = `
|
|
23
|
+
<div class="stats">
|
|
24
|
+
${stat('Total Sent', '0')}
|
|
25
|
+
</div>
|
|
26
|
+
<div class="card">
|
|
27
|
+
<div class="empty" style="padding:48px 16px">
|
|
28
|
+
<div style="font-size:14px;color:var(--fg-2);margin-bottom:6px">No notifications recorded</div>
|
|
29
|
+
<div style="font-size:12px;color:var(--fg-3)">Notifications are tracked when <code>@mantiq/notify</code> is installed.</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
`
|
|
33
|
+
return renderLayout({ title: 'Notifications', activePage: 'notifications', basePath, content })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rows = entries.map((entry) => {
|
|
37
|
+
let c: NotificationContent
|
|
38
|
+
try { c = JSON.parse(entry.content) } catch { return [] }
|
|
39
|
+
|
|
40
|
+
const statusVariant = c.status === 'sent' ? 'green' : c.status === 'failed' ? 'red' : 'mute'
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
`<span class="mono sm">${escapeHtml(c.type ?? '--')}</span>`,
|
|
44
|
+
`<span class="sm muted">${escapeHtml(c.channel ?? '--')}</span>`,
|
|
45
|
+
`<span class="sm muted">${escapeHtml(c.notifiable ?? '--')}</span>`,
|
|
46
|
+
badge(c.status ?? 'unknown', statusVariant as any),
|
|
47
|
+
`<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
|
|
48
|
+
]
|
|
49
|
+
}).filter((row) => row.length > 0)
|
|
50
|
+
|
|
51
|
+
const totalPages = Math.ceil(total / PER_PAGE)
|
|
52
|
+
|
|
53
|
+
const content = `
|
|
54
|
+
<div class="stats">
|
|
55
|
+
${stat('Total Sent', total.toString())}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="card">
|
|
59
|
+
${table(['Type', 'Channel', 'Notifiable', 'Status', 'Time'], rows)}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
${pagination(basePath + '/notifications', page, totalPages, searchParams)}
|
|
63
|
+
`
|
|
64
|
+
|
|
65
|
+
return renderLayout({ title: 'Notifications', activePage: 'notifications', basePath, content })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pagination(baseUrl: string, current: number, total: number, searchParams?: URLSearchParams): string {
|
|
69
|
+
if (total <= 1) return ''
|
|
70
|
+
|
|
71
|
+
function buildUrl(p: number): string {
|
|
72
|
+
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
73
|
+
params.set('page', String(p))
|
|
74
|
+
return `${baseUrl}?${params.toString()}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const items: string[] = []
|
|
78
|
+
if (current > 1) {
|
|
79
|
+
items.push(`<a href="${buildUrl(current - 1)}" style="padding:4px 10px;border-radius:4px;border:1px solid var(--border);color:var(--fg-2);text-decoration:none;font-size:12px">← Prev</a>`)
|
|
80
|
+
}
|
|
81
|
+
items.push(`<span class="sm dim">Page ${current} of ${total}</span>`)
|
|
82
|
+
if (current < total) {
|
|
83
|
+
items.push(`<a href="${buildUrl(current + 1)}" style="padding:4px 10px;border-radius:4px;border:1px solid var(--border);color:var(--fg-2);text-decoration:none;font-size:12px">Next →</a>`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
|
|
87
|
+
}
|
|
@@ -1,72 +1,136 @@
|
|
|
1
1
|
import { renderLayout } from '../shared/layout.ts'
|
|
2
|
-
import { stat, formatBytes } from '../shared/components.ts'
|
|
3
|
-
import {
|
|
2
|
+
import { stat, table, formatBytes, escapeHtml, truncate, durationBadge, badge, timeAgo } from '../shared/components.ts'
|
|
3
|
+
import { areaChart, ringChart } from '../shared/charts.ts'
|
|
4
4
|
import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
|
|
5
5
|
import type { MetricsCollector } from '../../metrics/MetricsCollector.ts'
|
|
6
|
-
import type { RequestEntryContent } from '../../contracts/Entry.ts'
|
|
6
|
+
import type { RequestEntryContent, ExceptionEntryContent } from '../../contracts/Entry.ts'
|
|
7
7
|
import { formatDuration } from '../../helpers/timing.ts'
|
|
8
8
|
|
|
9
9
|
export async function renderOverviewPage(store: HeartbeatStore, metrics: MetricsCollector, basePath: string): Promise<string> {
|
|
10
|
-
const
|
|
10
|
+
const now = Date.now()
|
|
11
|
+
const oneHourAgo = now - 3_600_000
|
|
12
|
+
|
|
13
|
+
const [
|
|
14
|
+
requestCount,
|
|
15
|
+
exceptionCount,
|
|
16
|
+
requestBuckets,
|
|
17
|
+
topSlowEndpoints,
|
|
18
|
+
topFrequentQueries,
|
|
19
|
+
recentExceptions,
|
|
20
|
+
recentRequests,
|
|
21
|
+
] = await Promise.all([
|
|
11
22
|
store.countEntries('request'),
|
|
12
|
-
store.countEntries('query'),
|
|
13
23
|
store.countEntries('exception'),
|
|
14
|
-
store.
|
|
15
|
-
store.
|
|
24
|
+
store.getTimeBuckets('request', 300_000, 12),
|
|
25
|
+
store.getTopSlowEndpoints(5, oneHourAgo),
|
|
26
|
+
store.getTopFrequentQueries(5, oneHourAgo),
|
|
27
|
+
store.getEntries({ type: 'exception', limit: 5 }),
|
|
16
28
|
store.getEntries({ type: 'request', limit: 200 }),
|
|
17
29
|
])
|
|
18
30
|
|
|
19
31
|
const p95 = metrics.percentile('http.requests.duration', 95)
|
|
20
32
|
const totalErrors = metrics.getCounter('http.errors.total')
|
|
21
33
|
const totalRequests = metrics.getCounter('http.requests.total')
|
|
22
|
-
const rss = metrics.getGauge('system.memory.rss')
|
|
23
34
|
const errorRate = totalRequests > 0 ? ((totalErrors / totalRequests) * 100).toFixed(1) + '%' : '0%'
|
|
24
35
|
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
// Row 1: 4 stat cards
|
|
37
|
+
const row1 = `
|
|
38
|
+
<div class="stats">
|
|
39
|
+
${stat('Total Requests', requestCount.toLocaleString(), 'All time')}
|
|
40
|
+
${stat('P95 Latency', formatDuration(p95), 'Response time')}
|
|
41
|
+
${stat('Error Rate', errorRate, `${totalErrors} of ${totalRequests}`)}
|
|
42
|
+
${stat('Active Exceptions', exceptionCount.toLocaleString(), 'Unresolved')}
|
|
43
|
+
</div>`
|
|
44
|
+
|
|
45
|
+
// Row 2: Request volume area chart — last hour, 12 five-minute buckets
|
|
46
|
+
const bucketLabels = requestBuckets.map((_, i) => {
|
|
47
|
+
const t = new Date(now - (12 - i) * 300_000)
|
|
48
|
+
return `${t.getHours().toString().padStart(2, '0')}:${t.getMinutes().toString().padStart(2, '0')}`
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const volumeChart = areaChart([
|
|
52
|
+
{ label: 'Requests', values: requestBuckets, color: 'var(--accent)' },
|
|
53
|
+
], bucketLabels)
|
|
54
|
+
|
|
55
|
+
const row2 = `
|
|
56
|
+
<div class="card mb">
|
|
57
|
+
<div class="card-title">Request Volume — Last Hour</div>
|
|
58
|
+
${volumeChart}
|
|
59
|
+
</div>`
|
|
60
|
+
|
|
61
|
+
// Row 3: Two columns — slow endpoints + frequent queries
|
|
62
|
+
const slowEndpointRows = topSlowEndpoints.map((ep) => [
|
|
63
|
+
`<span class="mono trunc sm" title="${escapeHtml(ep.path)}">${escapeHtml(truncate(ep.path, 40))}</span>`,
|
|
64
|
+
durationBadge(ep.avg_duration),
|
|
65
|
+
durationBadge(ep.max_duration),
|
|
66
|
+
`<span class="sm">${ep.count}</span>`,
|
|
67
|
+
])
|
|
30
68
|
|
|
31
|
-
const
|
|
32
|
-
|
|
69
|
+
const frequentQueryRows = topFrequentQueries.map((q) => [
|
|
70
|
+
`<span class="mono trunc sm" title="${escapeHtml(q.sql)}">${escapeHtml(truncate(q.sql, 40))}</span>`,
|
|
71
|
+
`<span class="sm">${q.count}</span>`,
|
|
72
|
+
durationBadge(q.avg_duration),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const row3 = `
|
|
76
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
|
|
77
|
+
<div class="card">
|
|
78
|
+
<div class="card-title">Top 5 Slowest Endpoints</div>
|
|
79
|
+
${table(['Path', 'Avg', 'Max', 'Hits'], slowEndpointRows)}
|
|
80
|
+
</div>
|
|
81
|
+
<div class="card">
|
|
82
|
+
<div class="card-title">Top 5 Most Frequent Queries</div>
|
|
83
|
+
${table(['SQL', 'Count', 'Avg Duration'], frequentQueryRows)}
|
|
84
|
+
</div>
|
|
85
|
+
</div>`
|
|
86
|
+
|
|
87
|
+
// Row 4: Recent exceptions
|
|
88
|
+
const exceptionRows = recentExceptions.map((entry) => {
|
|
89
|
+
const c = JSON.parse(entry.content) as ExceptionEntryContent
|
|
90
|
+
return [
|
|
91
|
+
`<span class="mono sm">${escapeHtml(c.class)}</span>`,
|
|
92
|
+
`<span class="trunc sm">${escapeHtml(truncate(c.message, 50))}</span>`,
|
|
93
|
+
c.file ? `<span class="mono sm dim">${escapeHtml(truncate(c.file, 30))}:${c.line ?? ''}</span>` : '<span class="dim">--</span>',
|
|
94
|
+
`<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
|
|
95
|
+
]
|
|
96
|
+
})
|
|
33
97
|
|
|
98
|
+
const row4 = `
|
|
99
|
+
<div class="card mb">
|
|
100
|
+
<div class="card-title">Recent Exceptions</div>
|
|
101
|
+
${table(['Class', 'Message', 'Location', 'Time'], exceptionRows)}
|
|
102
|
+
</div>`
|
|
103
|
+
|
|
104
|
+
// Row 5: Status code distribution ring chart
|
|
105
|
+
let count2xx = 0, count3xx = 0, count4xx = 0, count5xx = 0
|
|
34
106
|
for (const entry of recentRequests) {
|
|
35
|
-
if (entry.created_at < start) continue
|
|
36
|
-
const idx = Math.min(Math.floor((entry.created_at - start) / bucketSize), bucketCount - 1)
|
|
37
|
-
requestBuckets[idx]++
|
|
38
107
|
const c = JSON.parse(entry.content) as RequestEntryContent
|
|
39
|
-
if (c.status
|
|
108
|
+
if (c.status < 300) count2xx++
|
|
109
|
+
else if (c.status < 400) count3xx++
|
|
110
|
+
else if (c.status < 500) count4xx++
|
|
111
|
+
else count5xx++
|
|
40
112
|
}
|
|
41
113
|
|
|
42
|
-
const
|
|
43
|
-
const t = new Date(start + i * bucketSize)
|
|
44
|
-
return `${t.getHours().toString().padStart(2, '0')}:${t.getMinutes().toString().padStart(2, '0')}`
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
const chart = lineChart([
|
|
48
|
-
{ label: 'Requests', values: requestBuckets, color: 'var(--accent)' },
|
|
49
|
-
{ label: 'Errors', values: errorBuckets, color: 'var(--red)' },
|
|
50
|
-
], labels)
|
|
114
|
+
const totalStatusCodes = count2xx + count3xx + count4xx + count5xx
|
|
51
115
|
|
|
52
|
-
const
|
|
53
|
-
<div class="stats">
|
|
54
|
-
${stat('Requests', requestCount.toLocaleString(), 'Total recorded')}
|
|
55
|
-
${stat('P95 Latency', formatDuration(p95), 'Response time')}
|
|
56
|
-
${stat('Exceptions', exceptionCount.toLocaleString(), totalErrors > 0 ? `${errorRate} error rate` : 'None')}
|
|
57
|
-
${stat('Queries', queryCount.toLocaleString(), 'Total recorded')}
|
|
58
|
-
</div>
|
|
59
|
-
<div class="stats">
|
|
60
|
-
${stat('Jobs', jobCount.toLocaleString(), 'Processed')}
|
|
61
|
-
${stat('Cache', cacheCount.toLocaleString(), 'Operations')}
|
|
62
|
-
${stat('Memory', rss > 0 ? formatBytes(rss) : '--', 'RSS')}
|
|
63
|
-
${stat('Error Rate', errorRate, `${totalErrors} of ${totalRequests}`)}
|
|
64
|
-
</div>
|
|
116
|
+
const row5 = `
|
|
65
117
|
<div class="card">
|
|
66
|
-
<div class="card-title">
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
118
|
+
<div class="card-title">Status Code Distribution</div>
|
|
119
|
+
<div style="display:flex;align-items:center;justify-content:center;gap:32px;padding:16px 0">
|
|
120
|
+
${ringChart(count2xx, totalStatusCodes, { color: '#34d399', label: '2xx', size: 80 })}
|
|
121
|
+
${ringChart(count3xx, totalStatusCodes, { color: '#60a5fa', label: '3xx', size: 80 })}
|
|
122
|
+
${ringChart(count4xx, totalStatusCodes, { color: '#fbbf24', label: '4xx', size: 80 })}
|
|
123
|
+
${ringChart(count5xx, totalStatusCodes, { color: '#f87171', label: '5xx', size: 80 })}
|
|
124
|
+
</div>
|
|
125
|
+
<div style="text-align:center;font-size:11px;color:var(--fg-3)">
|
|
126
|
+
${badge(`${count2xx} success`, 'green')}
|
|
127
|
+
${badge(`${count3xx} redirect`, 'blue')}
|
|
128
|
+
${badge(`${count4xx} client err`, 'amber')}
|
|
129
|
+
${badge(`${count5xx} server err`, 'red')}
|
|
130
|
+
</div>
|
|
131
|
+
</div>`
|
|
132
|
+
|
|
133
|
+
const content = `${row1}${row2}${row3}${row4}${row5}`
|
|
70
134
|
|
|
71
135
|
return renderLayout({ title: 'Overview', activePage: 'overview', basePath, content })
|
|
72
136
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { renderLayout } from '../shared/layout.ts'
|
|
2
|
-
import { stat, formatBytes } from '../shared/components.ts'
|
|
2
|
+
import { stat, table, formatBytes, escapeHtml, truncate, durationBadge } from '../shared/components.ts'
|
|
3
|
+
import { lineChart, areaChart } from '../shared/charts.ts'
|
|
3
4
|
import { formatDuration } from '../../helpers/timing.ts'
|
|
5
|
+
import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
|
|
4
6
|
import type { MetricsCollector } from '../../metrics/MetricsCollector.ts'
|
|
7
|
+
import type { RequestEntryContent } from '../../contracts/Entry.ts'
|
|
5
8
|
|
|
6
|
-
export function renderPerformancePage(metrics: MetricsCollector, basePath: string): string {
|
|
9
|
+
export async function renderPerformancePage(store: HeartbeatStore, metrics: MetricsCollector, basePath: string, searchParams?: URLSearchParams): Promise<string> {
|
|
10
|
+
const range = searchParams?.get('range') ?? '1h'
|
|
11
|
+
const rangeMs = range === '24h' ? 86_400_000 : range === '6h' ? 21_600_000 : 3_600_000
|
|
12
|
+
const now = Date.now()
|
|
13
|
+
const since = now - rangeMs
|
|
14
|
+
|
|
15
|
+
const bucketMs = range === '24h' ? 3_600_000 : range === '6h' ? 900_000 : 300_000
|
|
16
|
+
const bucketCount = Math.ceil(rangeMs / bucketMs)
|
|
17
|
+
|
|
18
|
+
// Real-time metrics from collector
|
|
7
19
|
const p50 = metrics.percentile('http.requests.duration', 50)
|
|
8
20
|
const p95 = metrics.percentile('http.requests.duration', 95)
|
|
9
21
|
const p99 = metrics.percentile('http.requests.duration', 99)
|
|
@@ -15,34 +27,153 @@ export function renderPerformancePage(metrics: MetricsCollector, basePath: strin
|
|
|
15
27
|
const totalErrors = metrics.getCounter('http.errors.total')
|
|
16
28
|
const errorRate = totalRequests > 0 ? ((totalErrors / totalRequests) * 100).toFixed(1) + '%' : '0%'
|
|
17
29
|
|
|
30
|
+
// Fetch request entries for time-bucketed charts
|
|
31
|
+
const [requestEntries, topSlowEndpoints, topFrequentQueries, memoryMetrics] = await Promise.all([
|
|
32
|
+
store.getEntries({ type: 'request', limit: 2000 }),
|
|
33
|
+
store.getTopSlowEndpoints(10, since),
|
|
34
|
+
store.getTopFrequentQueries(10, since),
|
|
35
|
+
store.getMetrics('system.memory.rss', since),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
// Compute per-bucket latency percentiles and throughput from entries
|
|
39
|
+
const p50Buckets = new Array(bucketCount).fill(0)
|
|
40
|
+
const p95Buckets = new Array(bucketCount).fill(0)
|
|
41
|
+
const p99Buckets = new Array(bucketCount).fill(0)
|
|
42
|
+
const throughputBuckets = new Array(bucketCount).fill(0)
|
|
43
|
+
const errorBuckets = new Array(bucketCount).fill(0)
|
|
44
|
+
const bucketDurations: number[][] = Array.from({ length: bucketCount }, () => [])
|
|
45
|
+
|
|
46
|
+
for (const entry of requestEntries) {
|
|
47
|
+
if (entry.created_at < since) continue
|
|
48
|
+
const idx = Math.min(Math.floor((entry.created_at - since) / bucketMs), bucketCount - 1)
|
|
49
|
+
throughputBuckets[idx]++
|
|
50
|
+
|
|
51
|
+
const c = JSON.parse(entry.content) as RequestEntryContent
|
|
52
|
+
bucketDurations[idx]!.push(c.duration)
|
|
53
|
+
if (c.status >= 500) errorBuckets[idx]++
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
57
|
+
const durations = bucketDurations[i]!
|
|
58
|
+
if (durations.length === 0) continue
|
|
59
|
+
const sorted = [...durations].sort((a, b) => a - b)
|
|
60
|
+
p50Buckets[i] = sorted[Math.max(0, Math.ceil(sorted.length * 0.50) - 1)]!
|
|
61
|
+
p95Buckets[i] = sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)]!
|
|
62
|
+
p99Buckets[i] = sorted[Math.max(0, Math.ceil(sorted.length * 0.99) - 1)]!
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Error rate per bucket
|
|
66
|
+
const errorRateBuckets = throughputBuckets.map((total, i) =>
|
|
67
|
+
total > 0 ? (errorBuckets[i]! / total) * 100 : 0
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// Memory trend from stored metrics
|
|
71
|
+
const memoryBuckets = new Array(bucketCount).fill(0)
|
|
72
|
+
for (const m of memoryMetrics) {
|
|
73
|
+
const idx = Math.min(Math.floor((m.bucket * 60_000 - since) / bucketMs), bucketCount - 1)
|
|
74
|
+
if (idx >= 0 && idx < bucketCount) {
|
|
75
|
+
memoryBuckets[idx] = m.value
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Build bucket labels
|
|
80
|
+
const labels = Array.from({ length: bucketCount }, (_, i) => {
|
|
81
|
+
const t = new Date(since + i * bucketMs)
|
|
82
|
+
return `${t.getHours().toString().padStart(2, '0')}:${t.getMinutes().toString().padStart(2, '0')}`
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Range selector
|
|
86
|
+
const rangeSelector = `
|
|
87
|
+
<div style="display:flex;gap:6px;margin-bottom:16px">
|
|
88
|
+
${['1h', '6h', '24h'].map((r) =>
|
|
89
|
+
`<a href="${basePath}/performance?range=${r}" class="b ${r === range ? 'b-blue' : 'b-mute'}" style="text-decoration:none;font-size:11px">${r}</a>`
|
|
90
|
+
).join('')}
|
|
91
|
+
</div>`
|
|
92
|
+
|
|
93
|
+
// Stat cards
|
|
94
|
+
const statsRow = `
|
|
95
|
+
<div class="stats">
|
|
96
|
+
${stat('Avg', formatDuration(avg), 'Average')}
|
|
97
|
+
${stat('P50', formatDuration(p50), 'Median')}
|
|
98
|
+
${stat('P95', formatDuration(p95))}
|
|
99
|
+
${stat('P99', formatDuration(p99))}
|
|
100
|
+
</div>
|
|
101
|
+
<div class="stats">
|
|
102
|
+
${stat('Requests', totalRequests.toLocaleString())}
|
|
103
|
+
${stat('Errors', totalErrors.toLocaleString())}
|
|
104
|
+
${stat('Error Rate', errorRate)}
|
|
105
|
+
${stat('RSS', rss > 0 ? formatBytes(rss) : '--', 'Memory')}
|
|
106
|
+
</div>`
|
|
107
|
+
|
|
108
|
+
// Latency Trends chart
|
|
109
|
+
const latencyChart = lineChart([
|
|
110
|
+
{ label: 'P50', values: p50Buckets, color: '#34d399' },
|
|
111
|
+
{ label: 'P95', values: p95Buckets, color: '#fbbf24' },
|
|
112
|
+
{ label: 'P99', values: p99Buckets, color: '#f87171' },
|
|
113
|
+
], labels)
|
|
114
|
+
|
|
115
|
+
// Throughput chart
|
|
116
|
+
const throughputChart = areaChart([
|
|
117
|
+
{ label: 'Requests', values: throughputBuckets, color: 'var(--accent)' },
|
|
118
|
+
], labels)
|
|
119
|
+
|
|
120
|
+
// Error Rate chart
|
|
121
|
+
const errorRateChart = lineChart([
|
|
122
|
+
{ label: 'Error %', values: errorRateBuckets, color: '#f87171' },
|
|
123
|
+
], labels)
|
|
124
|
+
|
|
125
|
+
// Memory Trends chart
|
|
126
|
+
const memChart = lineChart([
|
|
127
|
+
{ label: 'RSS', values: memoryBuckets, color: '#a78bfa' },
|
|
128
|
+
], labels)
|
|
129
|
+
|
|
130
|
+
// Top Slow Endpoints table
|
|
131
|
+
const slowRows = topSlowEndpoints.map((ep) => [
|
|
132
|
+
`<span class="mono trunc sm" title="${escapeHtml(ep.path)}">${escapeHtml(truncate(ep.path, 40))}</span>`,
|
|
133
|
+
durationBadge(ep.avg_duration),
|
|
134
|
+
durationBadge(ep.max_duration),
|
|
135
|
+
`<span class="sm">${ep.count}</span>`,
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
// Top Slow Queries table
|
|
139
|
+
const queryRows = topFrequentQueries.map((q) => [
|
|
140
|
+
`<span class="mono trunc sm" title="${escapeHtml(q.sql)}">${escapeHtml(truncate(q.sql, 50))}</span>`,
|
|
141
|
+
`<span class="sm">${q.count}</span>`,
|
|
142
|
+
durationBadge(q.avg_duration),
|
|
143
|
+
])
|
|
144
|
+
|
|
18
145
|
const content = `
|
|
146
|
+
${rangeSelector}
|
|
147
|
+
${statsRow}
|
|
19
148
|
|
|
20
149
|
<div class="card mb">
|
|
21
|
-
<div class="card-title">Latency</div>
|
|
22
|
-
|
|
23
|
-
${stat('Avg', formatDuration(avg))}
|
|
24
|
-
${stat('P50', formatDuration(p50), 'Median')}
|
|
25
|
-
${stat('P95', formatDuration(p95))}
|
|
26
|
-
${stat('P99', formatDuration(p99))}
|
|
27
|
-
</div>
|
|
150
|
+
<div class="card-title">Latency Trends (ms)</div>
|
|
151
|
+
${latencyChart}
|
|
28
152
|
</div>
|
|
29
153
|
|
|
30
154
|
<div class="card mb">
|
|
31
155
|
<div class="card-title">Throughput</div>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
</div>
|
|
156
|
+
${throughputChart}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="card mb">
|
|
160
|
+
<div class="card-title">Error Rate (%)</div>
|
|
161
|
+
${errorRateChart}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="card mb">
|
|
165
|
+
<div class="card-title">Memory Trends (bytes)</div>
|
|
166
|
+
${memChart}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="card mb">
|
|
170
|
+
<div class="card-title">Top Slow Endpoints</div>
|
|
171
|
+
${table(['Path', 'Avg', 'Max', 'Hits'], slowRows)}
|
|
37
172
|
</div>
|
|
38
173
|
|
|
39
174
|
<div class="card">
|
|
40
|
-
<div class="card-title">
|
|
41
|
-
|
|
42
|
-
${stat('RSS', rss > 0 ? formatBytes(rss) : '--', 'Resident Set')}
|
|
43
|
-
${stat('Heap Used', heapUsed > 0 ? formatBytes(heapUsed) : '--')}
|
|
44
|
-
${stat('Heap Total', heapTotal > 0 ? formatBytes(heapTotal) : '--')}
|
|
45
|
-
</div>
|
|
175
|
+
<div class="card-title">Top Slow Queries</div>
|
|
176
|
+
${table(['SQL', 'Count', 'Avg Duration'], queryRows)}
|
|
46
177
|
</div>
|
|
47
178
|
`
|
|
48
179
|
|