@mantiq/heartbeat 0.5.22 → 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 (34) 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/middleware/HeartbeatMiddleware.ts +26 -9
  26. package/src/migrations/CreateHeartbeatTables.ts +14 -0
  27. package/src/models/EntryModel.ts +1 -1
  28. package/src/storage/HeartbeatStore.ts +174 -1
  29. package/src/testing/HeartbeatFake.ts +6 -0
  30. package/src/tracing/Tracer.ts +32 -0
  31. package/src/watchers/CommandWatcher.ts +23 -0
  32. package/src/watchers/EventWatcher.ts +13 -0
  33. package/src/watchers/ScheduleWatcher.ts +1 -0
  34. package/src/widget/DebugWidget.ts +53 -30
@@ -0,0 +1,216 @@
1
+ import { renderLayout } from '../shared/layout.ts'
2
+ import { badge, durationBadge, timeAgo, escapeHtml } from '../shared/components.ts'
3
+ import { formatDuration } from '../../helpers/timing.ts'
4
+ import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
5
+ import type { CommandEntryContent, HeartbeatEntry } from '../../contracts/Entry.ts'
6
+
7
+ const TYPE_ICONS: Record<string, string> = {
8
+ query: '\u26A1',
9
+ cache: '\uD83D\uDCE6',
10
+ event: '\u26A1',
11
+ exception: '\uD83D\uDD34',
12
+ log: '\uD83D\uDCDD',
13
+ job: '\u2699\uFE0F',
14
+ model: '\uD83D\uDD37',
15
+ mail: '\u2709\uFE0F',
16
+ schedule: '\u23F0',
17
+ }
18
+
19
+ const TYPE_COLORS: Record<string, string> = {
20
+ query: '#818cf8',
21
+ cache: '#34d399',
22
+ exception: '#f87171',
23
+ event: '#fbbf24',
24
+ log: '#94a3b8',
25
+ job: '#60a5fa',
26
+ model: '#a78bfa',
27
+ mail: '#2dd4bf',
28
+ schedule: '#fb923c',
29
+ }
30
+
31
+ export async function renderCommandDetailPage(store: HeartbeatStore, uuid: string, basePath: string): Promise<string | null> {
32
+ const entry = await store.getEntry(uuid)
33
+ if (!entry || entry.type !== 'command') return null
34
+
35
+ const c = JSON.parse(entry.content) as CommandEntryContent
36
+
37
+ // Find related entries created during this command
38
+ // The command's request_id is the commandId set by the tracer; child entries share this request_id
39
+ const relatedEntries = entry.request_id
40
+ ? (await store.getEntries({ requestId: entry.request_id, limit: 200 }))
41
+ .filter((e) => e.uuid !== entry.uuid)
42
+ : []
43
+
44
+ const recorded = new Date(entry.created_at)
45
+ const timeStr = `${recorded.getFullYear()}-${String(recorded.getMonth() + 1).padStart(2, '0')}-${String(recorded.getDate()).padStart(2, '0')} ${String(recorded.getHours()).padStart(2, '0')}:${String(recorded.getMinutes()).padStart(2, '0')}:${String(recorded.getSeconds()).padStart(2, '0')}`
46
+
47
+ const argsEntries = Object.entries(c.arguments ?? {})
48
+ const optionsEntries = Object.entries(c.options ?? {})
49
+
50
+ const content = `
51
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;font-size:13px">
52
+ <a href="${basePath}" style="color:var(--fg-3);text-decoration:none">Overview</a>
53
+ <span style="color:var(--fg-3)">&rsaquo;</span>
54
+ <a href="${basePath}/commands" style="color:var(--fg-3);text-decoration:none">Commands</a>
55
+ <span style="color:var(--fg-3)">&rsaquo;</span>
56
+ <span style="color:var(--fg-1)">${escapeHtml(c.name)}</span>
57
+ </div>
58
+
59
+ <div class="card mb" style="padding:16px 18px">
60
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
61
+ <code style="color:var(--fg-1);font-size:14px;font-weight:600;letter-spacing:-.01em">${escapeHtml(c.name)}</code>
62
+ ${badge(String(c.exit_code), c.exit_code === 0 ? 'green' : 'red')}
63
+ ${durationBadge(c.duration)}
64
+ <span class="sm dim" style="flex-shrink:0">${timeStr}</span>
65
+ </div>
66
+ <div style="margin-top:8px">
67
+ <code class="sm dim" style="font-size:11px">${entry.uuid}</code>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="stats">
72
+ ${stat('Exit Code', String(c.exit_code))}
73
+ ${stat('Duration', formatDuration(c.duration))}
74
+ ${stat('Arguments', String(argsEntries.length))}
75
+ </div>
76
+
77
+ ${argsEntries.length > 0 || optionsEntries.length > 0 ? `<div class="card mb">
78
+ <div class="card-title">Details</div>
79
+ ${argsEntries.length > 0 ? `<div style="margin-bottom:12px">
80
+ <div style="font-size:11px;font-weight:500;color:var(--fg-3);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px">Arguments</div>
81
+ <div class="meta-grid">
82
+ ${argsEntries.map(([k, v]) => `<div class="meta-item">
83
+ <div class="meta-label">${escapeHtml(k)}</div>
84
+ <div class="meta-value mono sm">${escapeHtml(typeof v === 'string' ? v : JSON.stringify(v))}</div>
85
+ </div>`).join('')}
86
+ </div>
87
+ </div>` : ''}
88
+ ${optionsEntries.length > 0 ? `<div>
89
+ <div style="font-size:11px;font-weight:500;color:var(--fg-3);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px">Options</div>
90
+ <div class="meta-grid">
91
+ ${optionsEntries.map(([k, v]) => `<div class="meta-item">
92
+ <div class="meta-label">${escapeHtml(k)}</div>
93
+ <div class="meta-value mono sm">${escapeHtml(typeof v === 'string' ? v : JSON.stringify(v))}</div>
94
+ </div>`).join('')}
95
+ </div>
96
+ </div>` : ''}
97
+ </div>` : ''}
98
+
99
+ ${c.output ? `<div class="card mb">
100
+ <div class="card-title">Output</div>
101
+ <pre class="mono sm" style="background:var(--bg-2);padding:12px;border-radius:6px;overflow-x:auto;color:var(--fg-1);max-height:400px;overflow-y:auto;margin:0;white-space:pre-wrap">${escapeHtml(c.output)}</pre>
102
+ </div>` : ''}
103
+
104
+ ${renderRelatedEntries(relatedEntries)}
105
+
106
+ <div style="margin-top:14px;font-size:11px;color:var(--fg-3)">${timeAgo(entry.created_at)}</div>
107
+ `
108
+
109
+ return renderLayout({ title: c.name, activePage: 'commands', basePath, content })
110
+ }
111
+
112
+ function stat(label: string, value: string): string {
113
+ return `<div class="stat">
114
+ <div class="stat-label">${label}</div>
115
+ <div class="stat-val" style="font-size:15px">${escapeHtml(value)}</div>
116
+ </div>`
117
+ }
118
+
119
+ // ── Related entries (queries, cache, events, logs, etc.) ─────────────────────
120
+
121
+ function renderRelatedEntries(entries: HeartbeatEntry[]): string {
122
+ if (entries.length === 0) return ''
123
+
124
+ const grouped = new Map<string, HeartbeatEntry[]>()
125
+ for (const e of entries) {
126
+ const list = grouped.get(e.type) ?? []
127
+ list.push(e)
128
+ grouped.set(e.type, list)
129
+ }
130
+
131
+ const order = ['query', 'exception', 'cache', 'log', 'event', 'job', 'model', 'mail', 'schedule']
132
+ const sortedTypes = [...grouped.keys()].sort((a, b) => {
133
+ const ai = order.indexOf(a), bi = order.indexOf(b)
134
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
135
+ })
136
+
137
+ let sections = ''
138
+ for (const type of sortedTypes) {
139
+ const list = grouped.get(type)!
140
+ const color = TYPE_COLORS[type] ?? 'var(--fg-3)'
141
+ const icon = TYPE_ICONS[type] ?? '\u2022'
142
+ const label = type.charAt(0).toUpperCase() + type.slice(1) + (list.length > 1 ? 's' : '')
143
+
144
+ sections += `<div style="margin-bottom:12px">
145
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
146
+ <span>${icon}</span>
147
+ <span style="font-weight:600;font-size:12px;color:${color};text-transform:uppercase;letter-spacing:.04em">${escapeHtml(label)}</span>
148
+ <span style="font-size:11px;color:var(--fg-3)">${list.length}</span>
149
+ </div>
150
+ <div style="display:flex;flex-direction:column;gap:4px">
151
+ ${list.map((e) => renderRelatedEntry(e, type)).join('')}
152
+ </div>
153
+ </div>`
154
+ }
155
+
156
+ return `<div class="card" style="margin-top:16px">
157
+ <div class="card-title" style="margin-bottom:12px">Command Timeline</div>
158
+ ${sections}
159
+ </div>`
160
+ }
161
+
162
+ function renderRelatedEntry(entry: HeartbeatEntry, type: string): string {
163
+ const content = JSON.parse(entry.content)
164
+ const color = TYPE_COLORS[type] ?? 'var(--fg-3)'
165
+ const ts = new Date(entry.created_at)
166
+ const time = `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}:${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`
167
+
168
+ let summary = ''
169
+ let detail = ''
170
+
171
+ switch (type) {
172
+ case 'query':
173
+ summary = escapeHtml(content.sql ?? content.normalized_sql ?? 'SQL query')
174
+ detail = content.duration != null ? `${content.duration.toFixed(1)}ms` : ''
175
+ if (content.slow) detail += ' <span style="color:#f87171;font-weight:600">SLOW</span>'
176
+ if (content.n_plus_one) detail += ' <span style="color:#fbbf24;font-weight:600">N+1</span>'
177
+ break
178
+ case 'cache':
179
+ summary = `${content.event ?? content.operation ?? 'operation'} \u2192 ${escapeHtml(content.key ?? '')}`
180
+ break
181
+ case 'event':
182
+ summary = escapeHtml(content.event_class ?? content.event ?? 'event')
183
+ detail = content.listeners_count != null ? `${content.listeners_count} listener${content.listeners_count === 1 ? '' : 's'}` : ''
184
+ break
185
+ case 'log':
186
+ summary = escapeHtml(content.message ?? '')
187
+ detail = content.level ?? ''
188
+ break
189
+ case 'exception':
190
+ summary = escapeHtml(content.class ?? content.message ?? 'exception')
191
+ detail = content.file ? escapeHtml(content.file + ':' + (content.line ?? '')) : ''
192
+ break
193
+ case 'job':
194
+ summary = escapeHtml(content.job_name ?? content.job ?? 'job')
195
+ detail = content.status ?? ''
196
+ break
197
+ case 'model':
198
+ summary = `${content.action ?? 'action'} ${escapeHtml(content.model_class ?? content.model ?? '')}`
199
+ detail = content.key ? `#${content.key}` : ''
200
+ break
201
+ case 'mail':
202
+ summary = escapeHtml(content.subject ?? 'email')
203
+ detail = content.to?.join(', ') ?? ''
204
+ break
205
+ default:
206
+ summary = JSON.stringify(content).slice(0, 80)
207
+ }
208
+
209
+ return `<div style="display:flex;align-items:baseline;gap:8px;padding:5px 8px;background:var(--bg-2);border-radius:4px;font-size:12px">
210
+ <code style="color:var(--fg-3);font-size:10px;flex-shrink:0">${time}</code>
211
+ <span style="border-left:2px solid ${color};padding-left:8px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
212
+ <code style="color:var(--fg-1)">${summary}</code>
213
+ </span>
214
+ ${detail ? `<span style="color:var(--fg-3);font-size:11px;flex-shrink:0">${detail}</span>` : ''}
215
+ </div>`
216
+ }
@@ -0,0 +1,72 @@
1
+ import { renderLayout } from '../shared/layout.ts'
2
+ import { table, badge, durationBadge, timeAgo, escapeHtml, truncate, stat } from '../shared/components.ts'
3
+ import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
+ import type { CommandEntryContent } from '../../contracts/Entry.ts'
5
+
6
+ const PER_PAGE = 50
7
+
8
+ export async function renderCommandsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
9
+ const page = Math.max(1, parseInt(searchParams?.get('page') ?? '1', 10))
10
+
11
+ const total = await store.countEntries('command')
12
+ const entries = await store.getEntries({ type: 'command', limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
13
+
14
+ let successCount = 0, failureCount = 0
15
+ for (const e of entries) {
16
+ const c = JSON.parse(e.content) as CommandEntryContent
17
+ if (c.exit_code === 0) successCount++
18
+ else failureCount++
19
+ }
20
+
21
+ const rows = entries.map((entry) => {
22
+ const c = JSON.parse(entry.content) as CommandEntryContent
23
+ const href = `${basePath}/commands/${entry.uuid}`
24
+ const argsPreview = truncate(JSON.stringify(c.arguments), 50)
25
+ return [
26
+ `<a href="${href}" class="mono sm" style="text-decoration:none;color:var(--fg-1)">${escapeHtml(c.name)}</a>`,
27
+ `<span class="mono trunc sm muted" title="${escapeHtml(argsPreview)}">${escapeHtml(argsPreview)}</span>`,
28
+ badge(String(c.exit_code), c.exit_code === 0 ? 'green' : 'red'),
29
+ durationBadge(c.duration),
30
+ `<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
31
+ ]
32
+ })
33
+
34
+ const totalPages = Math.ceil(total / PER_PAGE)
35
+
36
+ const content = `
37
+ <div class="stats">
38
+ ${stat('Total Commands', total.toString())}
39
+ ${stat('Success', successCount.toString(), 'Exit code 0')}
40
+ ${stat('Failures', failureCount.toString())}
41
+ </div>
42
+
43
+ <div class="card">
44
+ ${table(['Command', 'Arguments', 'Exit Code', 'Duration', 'Time'], rows)}
45
+ </div>
46
+
47
+ ${pagination(basePath + '/commands', page, totalPages, searchParams)}
48
+ `
49
+
50
+ return renderLayout({ title: 'Commands', activePage: 'commands', basePath, content })
51
+ }
52
+
53
+ function pagination(baseUrl: string, current: number, total: number, searchParams?: URLSearchParams): string {
54
+ if (total <= 1) return ''
55
+
56
+ function buildUrl(p: number): string {
57
+ const params = new URLSearchParams(searchParams?.toString() ?? '')
58
+ params.set('page', String(p))
59
+ return `${baseUrl}?${params.toString()}`
60
+ }
61
+
62
+ const items: string[] = []
63
+ if (current > 1) {
64
+ 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>`)
65
+ }
66
+ items.push(`<span class="sm dim">Page ${current} of ${total}</span>`)
67
+ if (current < total) {
68
+ 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>`)
69
+ }
70
+
71
+ return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
72
+ }
@@ -1,24 +1,77 @@
1
1
  import { renderLayout } from '../shared/layout.ts'
2
- import { table, badge, timeAgo, escapeHtml } from '../shared/components.ts'
2
+ import { table, badge, timeAgo, escapeHtml, truncate, stat, pagination } from '../shared/components.ts'
3
+ import { barChart } from '../shared/charts.ts'
3
4
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
5
  import type { EventEntryContent } from '../../contracts/Entry.ts'
5
6
 
6
- export async function renderEventsPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const entries = await store.getEntries({ type: 'event', limit: 100 })
7
+ const PER_PAGE = 50
8
8
 
9
- const rows = entries.map((entry) => {
9
+ export async function renderEventsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
10
+ const page = parseInt(searchParams?.get('page') ?? '1')
11
+
12
+ // Fetch all event entries for stats + frequency chart
13
+ const allEntries = await store.getEntries({ type: 'event', limit: 5000 })
14
+ const total = allEntries.length
15
+
16
+ // Compute event frequency for bar chart
17
+ const frequencyMap = new Map<string, number>()
18
+ for (const entry of allEntries) {
10
19
  const c = JSON.parse(entry.content) as EventEntryContent
20
+ const cls = c.event_class
21
+ frequencyMap.set(cls, (frequencyMap.get(cls) ?? 0) + 1)
22
+ }
23
+
24
+ const topEvents = Array.from(frequencyMap.entries())
25
+ .sort((a, b) => b[1] - a[1])
26
+ .slice(0, 10)
27
+ .map(([label, value]) => ({ label: truncate(label, 25), value, color: 'var(--accent)' }))
28
+
29
+ // Paginate
30
+ const pageEntries = allEntries.slice((page - 1) * PER_PAGE, page * PER_PAGE)
31
+
32
+ const rows = pageEntries.map((entry) => {
33
+ const c = JSON.parse(entry.content) as EventEntryContent
34
+
35
+ // Payload preview
36
+ let payloadHtml = '<span class="dim">--</span>'
37
+ if (c.payload && Object.keys(c.payload).length > 0) {
38
+ const preview = truncate(JSON.stringify(c.payload), 100)
39
+ payloadHtml = `<code class="mono sm" style="background:var(--bg-2);padding:2px 6px;border-radius:3px;font-size:10px">${escapeHtml(preview)}</code>`
40
+ }
41
+
42
+ // Listener names
43
+ let listenersHtml = `<span class="sm muted">${c.listeners_count} listener${c.listeners_count !== 1 ? 's' : ''}</span>`
44
+ if (c.listeners && c.listeners.length > 0) {
45
+ const listenerNames = c.listeners.map((l) => escapeHtml(l)).join(', ')
46
+ listenersHtml = `<span class="sm muted" title="${listenerNames}">${escapeHtml(truncate(c.listeners.join(', '), 60))}</span>`
47
+ }
48
+
11
49
  return [
12
50
  `<span class="mono sm">${escapeHtml(c.event_class)}</span>`,
13
- `<span class="sm muted">${c.listeners_count} listener${c.listeners_count !== 1 ? 's' : ''}</span>`,
51
+ listenersHtml,
52
+ payloadHtml,
14
53
  `<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
15
54
  ]
16
55
  })
17
56
 
57
+ const frequencyChart = topEvents.length > 0 ? barChart(topEvents) : ''
58
+
18
59
  const content = `
60
+ <div class="stats">
61
+ ${stat('Total Events', total.toString())}
62
+ ${stat('Unique Events', frequencyMap.size.toString(), 'Distinct classes')}
63
+ </div>
64
+
65
+ ${frequencyChart ? `
66
+ <div class="card mb">
67
+ <div class="card-title">Top 10 Events by Frequency</div>
68
+ <div style="padding:8px 0">${frequencyChart}</div>
69
+ </div>` : ''}
70
+
19
71
  <div class="card">
20
- ${table(['Event', 'Listeners', 'Time'], rows)}
72
+ ${table(['Event', 'Listeners', 'Payload', 'Time'], rows)}
21
73
  </div>
74
+ ${pagination(total, page, PER_PAGE, `${basePath}/events`)}
22
75
  `
23
76
 
24
77
  return renderLayout({ title: 'Events', activePage: 'events', basePath, content })
@@ -1,22 +1,48 @@
1
1
  import { renderLayout } from '../shared/layout.ts'
2
- import { table, badge, timeAgo, escapeHtml, truncate, stat } from '../shared/components.ts'
2
+ import { table, badge, timeAgo, escapeHtml, truncate, stat, pagination } from '../shared/components.ts'
3
+ import { sparkline } from '../shared/charts.ts'
3
4
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
5
  import type { ExceptionEntryContent } from '../../contracts/Entry.ts'
5
6
 
6
- export async function renderExceptionsPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const [groups, entries] = await Promise.all([
7
+ const PER_PAGE = 50
8
+
9
+ export async function renderExceptionsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
10
+ const page = parseInt(searchParams?.get('page') ?? '1')
11
+
12
+ const [groups, totalExceptions] = await Promise.all([
8
13
  store.getExceptionGroups(50),
9
- store.getEntries({ type: 'exception', limit: 50 }),
14
+ store.countEntries('exception'),
10
15
  ])
11
16
 
17
+ // Fetch paginated recent exceptions
18
+ const entries = await store.getEntries({ type: 'exception', limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
19
+
12
20
  const open = groups.filter((g) => !g.resolved_at).length
13
21
 
22
+ // Sparkline: exception count per hour over the last 24h
23
+ const now = Date.now()
24
+ const hourBuckets = new Array(24).fill(0)
25
+ const last24h = now - 86_400_000
26
+
27
+ // Fetch entries for sparkline (last 24h of exceptions)
28
+ const sparklineEntries = await store.getEntries({ type: 'exception', limit: 5000 })
29
+ for (const entry of sparklineEntries) {
30
+ if (entry.created_at < last24h) continue
31
+ const hourIdx = Math.min(Math.floor((entry.created_at - last24h) / 3_600_000), 23)
32
+ hourBuckets[hourIdx]++
33
+ }
34
+
35
+ const trendChart = sparkline(hourBuckets, { width: 200, height: 36, color: '#f87171' })
36
+
14
37
  const groupRows = groups.map((g) => [
15
38
  `<span class="mono">${escapeHtml(g.class)}</span>`,
16
39
  `<span class="trunc sm">${escapeHtml(truncate(g.message, 50))}</span>`,
17
40
  `<strong>${g.count}</strong>`,
18
41
  g.resolved_at ? badge('resolved', 'green') : badge('open', 'red'),
19
42
  `<span class="sm dim">${timeAgo(g.last_seen_at)}</span>`,
43
+ g.resolved_at
44
+ ? `<a href="${basePath}/api/exceptions/${encodeURIComponent(g.fingerprint)}/unresolve" style="font-size:11px;color:#fbbf24;text-decoration:none;border:1px solid #fbbf24;border-radius:4px;padding:2px 8px" onclick="return confirm('Unresolve this exception group?')">Unresolve</a>`
45
+ : `<a href="${basePath}/api/exceptions/${encodeURIComponent(g.fingerprint)}/resolve" style="font-size:11px;color:#34d399;text-decoration:none;border:1px solid #34d399;border-radius:4px;padding:2px 8px" onclick="return confirm('Resolve this exception group?')">Resolve</a>`,
20
46
  ])
21
47
 
22
48
  const recentRows = entries.map((entry) => {
@@ -31,18 +57,23 @@ export async function renderExceptionsPage(store: HeartbeatStore, basePath: stri
31
57
 
32
58
  const content = `
33
59
  <div class="stats">
34
- ${stat('Total', entries.length.toString())}
60
+ ${stat('Total', totalExceptions.toString())}
35
61
  ${stat('Groups', groups.length.toString(), 'Unique')}
36
62
  ${stat('Open', open.toString(), 'Unresolved')}
63
+ <div class="stat">
64
+ <div class="stat-label">Trend (24h)</div>
65
+ <div style="padding:4px 0">${trendChart}</div>
66
+ </div>
37
67
  </div>
38
68
  <div class="card">
39
69
  <div class="card-title">Exception Groups</div>
40
- ${table(['Class', 'Message', 'Count', 'Status', 'Last Seen'], groupRows)}
70
+ ${table(['Class', 'Message', 'Count', 'Status', 'Last Seen', 'Action'], groupRows)}
41
71
  </div>
42
72
  <div class="card mt">
43
73
  <div class="card-title">Recent</div>
44
74
  ${table(['Class', 'Message', 'Location', 'Time'], recentRows)}
45
75
  </div>
76
+ ${pagination(totalExceptions, page, PER_PAGE, `${basePath}/exceptions`)}
46
77
  `
47
78
 
48
79
  return renderLayout({ title: 'Exceptions', activePage: 'exceptions', basePath, content })
@@ -1,20 +1,41 @@
1
1
  import { renderLayout } from '../shared/layout.ts'
2
- import { table, badge, durationBadge, timeAgo, escapeHtml, truncate, stat } from '../shared/components.ts'
2
+ import { table, badge, durationBadge, timeAgo, escapeHtml, truncate, stat, filterBar, pagination } from '../shared/components.ts'
3
3
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
4
  import type { JobEntryContent } from '../../contracts/Entry.ts'
5
5
 
6
- export async function renderJobsPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const entries = await store.getEntries({ type: 'job', limit: 100 })
6
+ const PER_PAGE = 50
8
7
 
8
+ export async function renderJobsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
9
+ const page = parseInt(searchParams?.get('page') ?? '1')
10
+ const statusFilter = searchParams?.get('status') ?? ''
11
+ const queueSearch = searchParams?.get('queue') ?? ''
12
+
13
+ // Fetch all job entries for stats + filtering
14
+ const allEntries = await store.getEntries({ type: 'job', limit: 5000 })
15
+
16
+ // Apply filters in-memory
17
+ const filtered = allEntries.filter((e) => {
18
+ const c = JSON.parse(e.content) as JobEntryContent
19
+ if (statusFilter && c.status !== statusFilter) return false
20
+ if (queueSearch && !c.queue.toLowerCase().includes(queueSearch.toLowerCase())) return false
21
+ return true
22
+ })
23
+
24
+ const total = filtered.length
25
+
26
+ // Compute stats from filtered entries
9
27
  let processed = 0, failed = 0, processing = 0
10
- for (const e of entries) {
28
+ for (const e of filtered) {
11
29
  const s = (JSON.parse(e.content) as JobEntryContent).status
12
30
  if (s === 'processed') processed++
13
31
  else if (s === 'failed') failed++
14
32
  else processing++
15
33
  }
16
34
 
17
- const rows = entries.map((entry) => {
35
+ // Paginate
36
+ const pageEntries = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
37
+
38
+ const rows = pageEntries.map((entry) => {
18
39
  const c = JSON.parse(entry.content) as JobEntryContent
19
40
  const v = c.status === 'processed' ? 'green' : c.status === 'failed' ? 'red' : 'blue'
20
41
  return [
@@ -28,7 +49,34 @@ export async function renderJobsPage(store: HeartbeatStore, basePath: string): P
28
49
  ]
29
50
  })
30
51
 
52
+ // Build extra params for pagination
53
+ const extraParams: Record<string, string> = {}
54
+ if (statusFilter) extraParams['status'] = statusFilter
55
+ if (queueSearch) extraParams['queue'] = queueSearch
56
+
57
+ const filters = filterBar({
58
+ action: `${basePath}/jobs`,
59
+ searchPlaceholder: 'Search queue...',
60
+ searchValue: queueSearch,
61
+ filters: [
62
+ {
63
+ name: 'status',
64
+ label: 'Status',
65
+ options: [
66
+ { value: 'processing', label: 'Processing' },
67
+ { value: 'processed', label: 'Processed' },
68
+ { value: 'failed', label: 'Failed' },
69
+ ],
70
+ selected: statusFilter,
71
+ },
72
+ ],
73
+ })
74
+
75
+ // Rename search param to queue for the filter bar
76
+ const paginationBase = buildPaginationUrl(`${basePath}/jobs`, extraParams)
77
+
31
78
  const content = `
79
+ ${filters}
32
80
  <div class="stats">
33
81
  ${stat('Processed', processed.toString(), 'Completed')}
34
82
  ${stat('Failed', failed.toString())}
@@ -37,7 +85,15 @@ export async function renderJobsPage(store: HeartbeatStore, basePath: string): P
37
85
  <div class="card">
38
86
  ${table(['Job', 'Queue', 'Status', 'Duration', 'Attempts', 'Error', 'Time'], rows)}
39
87
  </div>
88
+ ${pagination(total, page, PER_PAGE, paginationBase)}
40
89
  `
41
90
 
42
91
  return renderLayout({ title: 'Jobs', activePage: 'jobs', basePath, content })
43
92
  }
93
+
94
+ function buildPaginationUrl(base: string, params: Record<string, string>): string {
95
+ const entries = Object.entries(params).filter(([, v]) => v)
96
+ if (entries.length === 0) return base
97
+ const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
98
+ return `${base}?${qs}`
99
+ }
@@ -0,0 +1,116 @@
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 { LogEntryContent } from '../../contracts/Entry.ts'
5
+
6
+ const PER_PAGE = 50
7
+
8
+ const LEVEL_VARIANTS: Record<string, 'red' | 'amber' | 'blue' | 'mute'> = {
9
+ emergency: 'red',
10
+ alert: 'red',
11
+ critical: 'red',
12
+ error: 'red',
13
+ warning: 'amber',
14
+ notice: 'blue',
15
+ info: 'blue',
16
+ debug: 'mute',
17
+ }
18
+
19
+ const LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']
20
+
21
+ function originBadge(entry: { origin_type: string }): string {
22
+ const v: Record<string, 'green' | 'blue' | 'amber' | 'mute'> = {
23
+ request: 'green',
24
+ command: 'blue',
25
+ schedule: 'amber',
26
+ job: 'blue',
27
+ }
28
+ return badge(entry.origin_type, v[entry.origin_type] ?? 'mute')
29
+ }
30
+
31
+ export async function renderLogsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
32
+ const page = Math.max(1, parseInt(searchParams?.get('page') ?? '1', 10))
33
+ const levelFilter = searchParams?.get('level') ?? ''
34
+ const search = searchParams?.get('q') ?? ''
35
+
36
+ const total = await store.countEntries('log')
37
+ const entries = await store.getEntries({ type: 'log', limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
38
+
39
+ // Count by level from fetched + total entries
40
+ let errorCount = 0, warningCount = 0
41
+ const filtered: typeof entries = []
42
+ for (const e of entries) {
43
+ const c = JSON.parse(e.content) as LogEntryContent
44
+ if (c.level === 'error' || c.level === 'critical' || c.level === 'alert' || c.level === 'emergency') errorCount++
45
+ if (c.level === 'warning') warningCount++
46
+
47
+ if (levelFilter && c.level !== levelFilter) continue
48
+ if (search && !c.message.toLowerCase().includes(search.toLowerCase())) continue
49
+ filtered.push(e)
50
+ }
51
+
52
+ const rows = filtered.map((entry) => {
53
+ const c = JSON.parse(entry.content) as LogEntryContent
54
+ return [
55
+ badge(c.level, LEVEL_VARIANTS[c.level] ?? 'mute'),
56
+ `<span class="mono trunc sm" title="${escapeHtml(c.message)}">${escapeHtml(truncate(c.message, 80))}</span>`,
57
+ `<span class="sm muted">${escapeHtml(c.channel)}</span>`,
58
+ originBadge(entry),
59
+ `<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
60
+ ]
61
+ })
62
+
63
+ const totalPages = Math.ceil(total / PER_PAGE)
64
+
65
+ const levelOptions = LEVELS
66
+ .map((l) => `<option value="${l}"${levelFilter === l ? ' selected' : ''}>${l}</option>`)
67
+ .join('')
68
+
69
+ const content = `
70
+ <div class="stats">
71
+ ${stat('Total Logs', total.toString())}
72
+ ${stat('Errors', errorCount.toString())}
73
+ ${stat('Warnings', warningCount.toString())}
74
+ </div>
75
+
76
+ <div class="card mb" style="padding:10px 14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
77
+ <form method="get" action="${basePath}/logs" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex:1">
78
+ <select name="level" style="padding:5px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-2);color:var(--fg-1);font-size:12px">
79
+ <option value="">All levels</option>
80
+ ${levelOptions}
81
+ </select>
82
+ <input type="text" name="q" value="${escapeHtml(search)}" placeholder="Search message\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">
83
+ <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>
84
+ </form>
85
+ </div>
86
+
87
+ <div class="card">
88
+ ${table(['Level', 'Message', 'Channel', 'Origin', 'Time'], rows)}
89
+ </div>
90
+
91
+ ${pagination(basePath + '/logs', page, totalPages, searchParams)}
92
+ `
93
+
94
+ return renderLayout({ title: 'Logs', activePage: 'logs', basePath, content })
95
+ }
96
+
97
+ function pagination(baseUrl: string, current: number, total: number, searchParams?: URLSearchParams): string {
98
+ if (total <= 1) return ''
99
+
100
+ function buildUrl(p: number): string {
101
+ const params = new URLSearchParams(searchParams?.toString() ?? '')
102
+ params.set('page', String(p))
103
+ return `${baseUrl}?${params.toString()}`
104
+ }
105
+
106
+ const items: string[] = []
107
+ if (current > 1) {
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">&larr; Prev</a>`)
109
+ }
110
+ items.push(`<span class="sm dim">Page ${current} of ${total}</span>`)
111
+ if (current < total) {
112
+ 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>`)
113
+ }
114
+
115
+ return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
116
+ }