@mantiq/heartbeat 0.5.21 → 0.5.23

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/Heartbeat.ts +15 -27
  3. package/src/HeartbeatServiceProvider.ts +66 -0
  4. package/src/contracts/Entry.ts +19 -0
  5. package/src/contracts/HeartbeatConfig.ts +2 -0
  6. package/src/dashboard/DashboardController.ts +71 -12
  7. package/src/dashboard/pages/CachePage.ts +61 -5
  8. package/src/dashboard/pages/CommandDetailPage.ts +216 -0
  9. package/src/dashboard/pages/CommandsPage.ts +72 -0
  10. package/src/dashboard/pages/EventsPage.ts +59 -6
  11. package/src/dashboard/pages/ExceptionsPage.ts +37 -6
  12. package/src/dashboard/pages/JobsPage.ts +61 -5
  13. package/src/dashboard/pages/LogsPage.ts +116 -0
  14. package/src/dashboard/pages/ModelsPage.ts +112 -0
  15. package/src/dashboard/pages/NotificationsPage.ts +87 -0
  16. package/src/dashboard/pages/OverviewPage.ts +109 -45
  17. package/src/dashboard/pages/PerformancePage.ts +151 -20
  18. package/src/dashboard/pages/QueriesPage.ts +92 -8
  19. package/src/dashboard/pages/RequestDetailPage.ts +227 -3
  20. package/src/dashboard/pages/RequestsPage.ts +90 -3
  21. package/src/dashboard/pages/SchedulesPage.ts +71 -0
  22. package/src/dashboard/shared/components.ts +140 -0
  23. package/src/dashboard/shared/layout.ts +327 -108
  24. package/src/index.ts +9 -0
  25. package/src/metrics/MetricsCollector.ts +7 -3
  26. package/src/middleware/HeartbeatMiddleware.ts +26 -9
  27. package/src/migrations/CreateHeartbeatTables.ts +14 -0
  28. package/src/models/EntryModel.ts +1 -1
  29. package/src/storage/HeartbeatStore.ts +174 -1
  30. package/src/testing/HeartbeatFake.ts +6 -0
  31. package/src/tracing/Tracer.ts +32 -0
  32. package/src/watchers/CommandWatcher.ts +23 -0
  33. package/src/watchers/EventWatcher.ts +13 -0
  34. package/src/watchers/ScheduleWatcher.ts +1 -0
  35. 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">&larr; 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 &rarr;</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">&larr; 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 &rarr;</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 { lineChart } from '../shared/charts.ts'
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 [requestCount, queryCount, exceptionCount, jobCount, cacheCount, recentRequests] = await Promise.all([
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.countEntries('job'),
15
- store.countEntries('cache'),
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
- // Bucket requests by minute for the last 30 minutes
26
- const now = Date.now()
27
- const bucketSize = 60 * 1000 // 1 minute
28
- const bucketCount = 30
29
- const start = now - bucketCount * bucketSize
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 &mdash; 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 requestBuckets = new Array(bucketCount).fill(0)
32
- const errorBuckets = new Array(bucketCount).fill(0)
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 >= 500) errorBuckets[idx]++
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 labels = requestBuckets.map((_, i) => {
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 content = `
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">Request Volume — Last Hour</div>
67
- ${chart}
68
- </div>
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
- <div class="stats" style="margin-bottom:0">
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
- <div class="stats" style="margin-bottom:0">
33
- ${stat('Requests', totalRequests.toLocaleString())}
34
- ${stat('Errors', totalErrors.toLocaleString())}
35
- ${stat('Error Rate', errorRate)}
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">Memory</div>
41
- <div class="stats" style="margin-bottom:0">
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