@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
@@ -1,20 +1,53 @@
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, sqlHighlight } from '../shared/components.ts'
3
3
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
4
  import type { QueryEntryContent } from '../../contracts/Entry.ts'
5
+ import { formatDuration } from '../../helpers/timing.ts'
5
6
 
6
- export async function renderQueriesPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const entries = await store.getEntries({ type: 'query', limit: 100 })
7
+ const PER_PAGE = 50
8
+
9
+ export async function renderQueriesPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
10
+ const page = parseInt(searchParams?.get('page') ?? '1')
11
+ const search = searchParams?.get('search') ?? ''
12
+
13
+ // Fetch all query entries for stats computation, then paginate
14
+ const allEntries = await store.getEntries({ type: 'query', limit: 5000 })
15
+
16
+ // Apply search filter in-memory
17
+ const filtered = search
18
+ ? allEntries.filter((e) => {
19
+ const c = JSON.parse(e.content) as QueryEntryContent
20
+ return c.sql.toLowerCase().includes(search.toLowerCase()) || c.normalized_sql.toLowerCase().includes(search.toLowerCase())
21
+ })
22
+ : allEntries
23
+
24
+ const total = filtered.length
8
25
 
9
26
  let slowCount = 0
10
27
  let nplusOneCount = 0
11
- for (const e of entries) {
28
+ let totalDuration = 0
29
+ const slowEntries: typeof filtered = []
30
+ const nplusOneEntries: typeof filtered = []
31
+
32
+ for (const e of filtered) {
12
33
  const c = JSON.parse(e.content) as QueryEntryContent
13
- if (c.slow) slowCount++
14
- if (c.n_plus_one) nplusOneCount++
34
+ totalDuration += c.duration
35
+ if (c.slow) {
36
+ slowCount++
37
+ slowEntries.push(e)
38
+ }
39
+ if (c.n_plus_one) {
40
+ nplusOneCount++
41
+ nplusOneEntries.push(e)
42
+ }
15
43
  }
16
44
 
17
- const rows = entries.map((entry) => {
45
+ const avgDuration = filtered.length > 0 ? totalDuration / filtered.length : 0
46
+
47
+ // Paginate
48
+ const pageEntries = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
49
+
50
+ const rows = pageEntries.map((entry) => {
18
51
  const c = JSON.parse(entry.content) as QueryEntryContent
19
52
  const flags = []
20
53
  if (c.slow) flags.push(badge('slow', 'red'))
@@ -29,15 +62,66 @@ export async function renderQueriesPage(store: HeartbeatStore, basePath: string)
29
62
  ]
30
63
  })
31
64
 
65
+ // Slow queries table
66
+ const slowRows = slowEntries.slice(0, 20).map((entry) => {
67
+ const c = JSON.parse(entry.content) as QueryEntryContent
68
+ return [
69
+ `<div>${sqlHighlight(truncate(c.sql, 80))}</div>`,
70
+ `<span class="sm muted">${escapeHtml(c.connection)}</span>`,
71
+ durationBadge(c.duration, 100),
72
+ c.caller ? `<span class="mono sm dim">${escapeHtml(truncate(c.caller, 40))}</span>` : '<span class="dim">--</span>',
73
+ `<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
74
+ ]
75
+ })
76
+
77
+ // N+1 detected table
78
+ const nplusOneRows = nplusOneEntries.slice(0, 20).map((entry) => {
79
+ const c = JSON.parse(entry.content) as QueryEntryContent
80
+ return [
81
+ `<div>${sqlHighlight(truncate(c.sql, 80))}</div>`,
82
+ `<span class="sm muted">${escapeHtml(c.connection)}</span>`,
83
+ durationBadge(c.duration, 100),
84
+ c.caller ? `<span class="mono sm dim">${escapeHtml(truncate(c.caller, 40))}</span>` : '<span class="dim">--</span>',
85
+ `<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
86
+ ]
87
+ })
88
+
89
+ // Build filter bar
90
+ const extraParams: Record<string, string> = {}
91
+ if (search) extraParams['search'] = search
92
+
93
+ const filters = filterBar({
94
+ action: `${basePath}/queries`,
95
+ searchPlaceholder: 'Search SQL...',
96
+ searchValue: search,
97
+ })
98
+
99
+ const paginationBase = search ? `${basePath}/queries?search=${encodeURIComponent(search)}` : `${basePath}/queries`
100
+
32
101
  const content = `
102
+ ${filters}
33
103
  <div class="stats">
34
- ${stat('Total', entries.length.toString())}
104
+ ${stat('Total', total.toString())}
105
+ ${stat('Avg Duration', formatDuration(avgDuration), 'Per query')}
35
106
  ${stat('Slow', slowCount.toString(), '> threshold')}
36
107
  ${stat('N+1', nplusOneCount.toString(), 'Detected')}
37
108
  </div>
38
109
  <div class="card">
39
110
  ${table(['SQL', 'Connection', 'Duration', 'Flags', 'Time'], rows)}
40
111
  </div>
112
+ ${pagination(total, page, PER_PAGE, paginationBase)}
113
+
114
+ ${slowEntries.length > 0 ? `
115
+ <div class="card mt">
116
+ <div class="card-title">Slow Queries <span class="sm dim">(${slowCount})</span></div>
117
+ ${table(['SQL', 'Connection', 'Duration', 'Caller', 'Time'], slowRows)}
118
+ </div>` : ''}
119
+
120
+ ${nplusOneEntries.length > 0 ? `
121
+ <div class="card mt">
122
+ <div class="card-title">N+1 Detected <span class="sm dim">(${nplusOneCount})</span></div>
123
+ ${table(['SQL', 'Connection', 'Duration', 'Caller', 'Time'], nplusOneRows)}
124
+ </div>` : ''}
41
125
  `
42
126
 
43
127
  return renderLayout({ title: 'Queries', activePage: 'queries', basePath, content })
@@ -1,8 +1,8 @@
1
1
  import { renderLayout } from '../shared/layout.ts'
2
- import { statusBadge, methodBadge, durationBadge, timeAgo, escapeHtml, formatBytes } from '../shared/components.ts'
2
+ import { statusBadge, methodBadge, durationBadge, timeAgo, escapeHtml, formatBytes, breadcrumbs, collapsibleSection, sqlHighlight, diffView, waterfallChart } from '../shared/components.ts'
3
3
  import { formatDuration } from '../../helpers/timing.ts'
4
4
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
5
- import type { RequestEntryContent } from '../../contracts/Entry.ts'
5
+ import type { RequestEntryContent, HeartbeatEntry } from '../../contracts/Entry.ts'
6
6
 
7
7
  const COPY_ICON = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
8
8
  const CHECK_ICON = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
@@ -12,10 +12,24 @@ export async function renderRequestDetailPage(store: HeartbeatStore, uuid: strin
12
12
  if (!entry || entry.type !== 'request') return null
13
13
 
14
14
  const c = JSON.parse(entry.content) as RequestEntryContent
15
+
16
+ // Fetch all entries that share this request_id (queries, cache, events, logs, exceptions, etc.)
17
+ const relatedEntries = entry.request_id
18
+ ? (await store.getEntries({ requestId: entry.request_id, limit: 200 }))
19
+ .filter((e) => e.uuid !== entry.uuid)
20
+ : []
21
+
15
22
  const recorded = new Date(entry.created_at)
16
23
  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')}`
17
24
  const requestLine = `${c.method} ${c.url || c.path}`
18
25
 
26
+ // Breadcrumbs
27
+ const breadcrumbsHtml = breadcrumbs([
28
+ { label: 'Overview', href: basePath },
29
+ { label: 'Requests', href: `${basePath}/requests` },
30
+ { label: `${c.method} ${c.path}` },
31
+ ])
32
+
19
33
  // Build request tab sections
20
34
  const requestSections: string[] = []
21
35
  requestSections.push(kvTable('Headers', c.request_headers))
@@ -51,8 +65,9 @@ export async function renderRequestDetailPage(store: HeartbeatStore, uuid: strin
51
65
  const mdEscaped = md.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')
52
66
 
53
67
  const content = `
68
+ ${breadcrumbsHtml}
69
+
54
70
  <div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
55
- <a href="${basePath}/requests" style="color:var(--fg-3);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">&larr; <span>Requests</span></a>
56
71
  <div style="margin-left:auto;display:flex;align-items:center;gap:8px">
57
72
  <button class="copy-btn" onclick="copyMd()" title="Copy full request as Markdown">${COPY_ICON}<span>Copy as Markdown</span></button>
58
73
  </div>
@@ -103,6 +118,8 @@ export async function renderRequestDetailPage(store: HeartbeatStore, uuid: strin
103
118
  </div>
104
119
  </div>
105
120
 
121
+ ${renderRelatedEntries(relatedEntries, c.duration, basePath)}
122
+
106
123
  <div style="margin-top:14px;font-size:11px;color:var(--fg-3)">${timeAgo(entry.created_at)}</div>
107
124
 
108
125
  <script>
@@ -292,3 +309,210 @@ function buildMarkdown(
292
309
 
293
310
  return lines.join('\n')
294
311
  }
312
+
313
+ // ── Related entries (queries, cache, events, logs, etc.) ─────────────────────
314
+
315
+ const TYPE_ICONS: Record<string, string> = {
316
+ query: '&#9889;',
317
+ cache: '&#128230;',
318
+ event: '&#9889;',
319
+ exception: '&#128308;',
320
+ log: '&#128221;',
321
+ job: '&#9881;',
322
+ model: '&#128311;',
323
+ mail: '&#9993;',
324
+ schedule: '&#9200;',
325
+ }
326
+
327
+ const TYPE_COLORS: Record<string, string> = {
328
+ query: '#818cf8',
329
+ cache: '#34d399',
330
+ exception: '#f87171',
331
+ event: '#fbbf24',
332
+ log: '#94a3b8',
333
+ job: '#60a5fa',
334
+ model: '#a78bfa',
335
+ mail: '#2dd4bf',
336
+ schedule: '#fb923c',
337
+ }
338
+
339
+ function renderRelatedEntries(entries: HeartbeatEntry[], requestDuration: number, _basePath: string): string {
340
+ if (entries.length === 0) return ''
341
+
342
+ const grouped = new Map<string, HeartbeatEntry[]>()
343
+ for (const e of entries) {
344
+ const list = grouped.get(e.type) ?? []
345
+ list.push(e)
346
+ grouped.set(e.type, list)
347
+ }
348
+
349
+ // Preferred display order
350
+ const order = ['query', 'exception', 'cache', 'log', 'event', 'job', 'model', 'mail', 'schedule']
351
+ const sortedTypes = [...grouped.keys()].sort((a, b) => {
352
+ const ai = order.indexOf(a), bi = order.indexOf(b)
353
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
354
+ })
355
+
356
+ // Build waterfall chart data from all related entries
357
+ const requestStartTs = entries.length > 0
358
+ ? Math.min(...entries.map((e) => e.created_at), entries[0]!.created_at)
359
+ : 0
360
+
361
+ const waterfallItems: Array<{ label: string; start: number; end: number; color: string }> = []
362
+
363
+ for (const entry of entries) {
364
+ const content = JSON.parse(entry.content)
365
+ const type = entry.type
366
+ const color = TYPE_COLORS[type] ?? 'var(--fg-3)'
367
+ const startOffset = entry.created_at - requestStartTs
368
+ let duration = 0
369
+
370
+ if (type === 'query' && typeof content.duration === 'number') {
371
+ duration = content.duration
372
+ } else if (type === 'job' && typeof content.duration === 'number') {
373
+ duration = content.duration
374
+ } else if (type === 'cache' && typeof content.duration === 'number') {
375
+ duration = content.duration
376
+ } else {
377
+ duration = 0.5 // minimal width for entries without duration
378
+ }
379
+
380
+ let label: string = type
381
+ if (type === 'query') label = 'SQL'
382
+ else if (type === 'cache') label = `cache:${content.event ?? content.operation ?? 'op'}`
383
+ else if (type === 'exception') label = content.class ?? 'exception'
384
+ else if (type === 'event') label = content.event_class ?? 'event'
385
+ else if (type === 'model') label = `${content.action ?? 'model'} ${content.model ?? ''}`
386
+
387
+ waterfallItems.push({ label, start: startOffset, end: startOffset + duration, color })
388
+ }
389
+
390
+ const waterfallHtml = waterfallItems.length > 0
391
+ ? `<div class="card mb" style="margin-top:16px">
392
+ <div class="card-title" style="margin-bottom:12px">Waterfall Timeline</div>
393
+ ${waterfallChart(waterfallItems, requestDuration)}
394
+ </div>`
395
+ : ''
396
+
397
+ let sections = ''
398
+ for (const type of sortedTypes) {
399
+ const list = grouped.get(type)!
400
+ const color = TYPE_COLORS[type] ?? 'var(--fg-3)'
401
+ const icon = TYPE_ICONS[type] ?? '&bull;'
402
+ const label = type.charAt(0).toUpperCase() + type.slice(1) + (list.length > 1 ? 's' : '')
403
+
404
+ const entriesHtml = `<div style="display:flex;flex-direction:column;gap:4px">
405
+ ${list.map((e) => renderRelatedEntry(e, type)).join('')}
406
+ </div>`
407
+
408
+ sections += collapsibleSection(
409
+ `${icon} ${label}`,
410
+ list.length,
411
+ color,
412
+ entriesHtml,
413
+ )
414
+ }
415
+
416
+ return `${waterfallHtml}
417
+ <div class="card" style="margin-top:16px">
418
+ <div class="card-title" style="margin-bottom:12px">Request Timeline</div>
419
+ ${sections}
420
+ </div>`
421
+ }
422
+
423
+ function renderRelatedEntry(entry: HeartbeatEntry, type: string): string {
424
+ const content = JSON.parse(entry.content)
425
+ const color = TYPE_COLORS[type] ?? 'var(--fg-3)'
426
+ const ts = new Date(entry.created_at)
427
+ 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')}`
428
+
429
+ let summary = ''
430
+ let detail = ''
431
+
432
+ switch (type) {
433
+ case 'query':
434
+ // Use sqlHighlight for query SQL display
435
+ summary = sqlHighlight(content.sql ?? content.normalized_sql ?? 'SQL query')
436
+ detail = content.duration != null ? `${content.duration.toFixed(1)}ms` : ''
437
+ if (content.slow) detail += ' <span style="color:#f87171;font-weight:600">SLOW</span>'
438
+ if (content.n_plus_one) detail += ' <span style="color:#fbbf24;font-weight:600">N+1</span>'
439
+ return `<div style="padding:5px 8px;background:var(--bg-2);border-radius:4px;font-size:12px">
440
+ <div style="display:flex;align-items:baseline;gap:8px">
441
+ <code style="color:var(--fg-3);font-size:10px;flex-shrink:0">${time}</code>
442
+ <span style="border-left:2px solid ${color};padding-left:8px;flex:1;overflow:hidden">
443
+ ${summary}
444
+ </span>
445
+ ${detail ? `<span style="color:var(--fg-3);font-size:11px;flex-shrink:0">${detail}</span>` : ''}
446
+ </div>
447
+ </div>`
448
+
449
+ case 'cache': {
450
+ const op = content.event ?? content.operation ?? 'operation'
451
+ const isHit = op === 'hit'
452
+ const isMiss = op === 'miss'
453
+ const indicator = isHit
454
+ ? '<span style="color:#34d399;font-weight:700" title="Cache Hit">&#10003;</span>'
455
+ : isMiss
456
+ ? '<span style="color:#f87171;font-weight:700" title="Cache Miss">&#10007;</span>'
457
+ : ''
458
+ summary = `${indicator} ${op} &rarr; ${escapeHtml(content.key ?? '')}`
459
+ break
460
+ }
461
+
462
+ case 'event':
463
+ summary = escapeHtml(content.event_class ?? content.event ?? 'event')
464
+ detail = content.listeners_count != null ? `${content.listeners_count} listener${content.listeners_count === 1 ? '' : 's'}` : ''
465
+ break
466
+
467
+ case 'log':
468
+ summary = escapeHtml(content.message ?? '')
469
+ detail = content.level ?? ''
470
+ break
471
+
472
+ case 'exception':
473
+ summary = escapeHtml(content.class ?? content.message ?? 'exception')
474
+ detail = content.file ? escapeHtml(content.file + ':' + (content.line ?? '')) : ''
475
+ break
476
+
477
+ case 'job':
478
+ summary = escapeHtml(content.job_name ?? content.job ?? 'job')
479
+ detail = content.status ?? ''
480
+ break
481
+
482
+ case 'model': {
483
+ summary = `${content.action ?? 'action'} ${escapeHtml(content.model ?? '')}`
484
+ detail = content.key ? `#${content.key}` : ''
485
+ // Use diffView for model change entries
486
+ if (content.changes && Object.keys(content.changes).length > 0) {
487
+ const diffHtml = diffView(content.changes)
488
+ return `<div style="padding:5px 8px;background:var(--bg-2);border-radius:4px;font-size:12px">
489
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:6px">
490
+ <code style="color:var(--fg-3);font-size:10px;flex-shrink:0">${time}</code>
491
+ <span style="border-left:2px solid ${color};padding-left:8px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
492
+ <code style="color:var(--fg-1)">${summary}</code>
493
+ </span>
494
+ ${detail ? `<span style="color:var(--fg-3);font-size:11px;flex-shrink:0">${detail}</span>` : ''}
495
+ </div>
496
+ <div style="padding-left:22px">${diffHtml}</div>
497
+ </div>`
498
+ }
499
+ break
500
+ }
501
+
502
+ case 'mail':
503
+ summary = escapeHtml(content.subject ?? 'email')
504
+ detail = content.to?.join(', ') ?? ''
505
+ break
506
+
507
+ default:
508
+ summary = JSON.stringify(content).slice(0, 80)
509
+ }
510
+
511
+ return `<div style="display:flex;align-items:baseline;gap:8px;padding:5px 8px;background:var(--bg-2);border-radius:4px;font-size:12px">
512
+ <code style="color:var(--fg-3);font-size:10px;flex-shrink:0">${time}</code>
513
+ <span style="border-left:2px solid ${color};padding-left:8px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
514
+ <code style="color:var(--fg-1)">${summary}</code>
515
+ </span>
516
+ ${detail ? `<span style="color:var(--fg-3);font-size:11px;flex-shrink:0">${detail}</span>` : ''}
517
+ </div>`
518
+ }
@@ -1,10 +1,48 @@
1
1
  import { renderLayout } from '../shared/layout.ts'
2
- import { table, statusBadge, methodBadge, durationBadge, timeAgo, escapeHtml, truncate } from '../shared/components.ts'
2
+ import { table, statusBadge, methodBadge, durationBadge, timeAgo, escapeHtml, truncate, filterBar, pagination } from '../shared/components.ts'
3
3
  import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
4
  import type { RequestEntryContent } from '../../contracts/Entry.ts'
5
5
 
6
- export async function renderRequestsPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const entries = await store.getEntries({ type: 'request', limit: 100 })
6
+ const PER_PAGE = 50
7
+
8
+ export async function renderRequestsPage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
9
+ const page = parseInt(searchParams?.get('page') ?? '1')
10
+ const method = searchParams?.get('method') ?? ''
11
+ const status = searchParams?.get('status') ?? ''
12
+ const search = searchParams?.get('search') ?? ''
13
+
14
+ // Build status range for search
15
+ let statusMin: number | undefined
16
+ let statusMax: number | undefined
17
+ if (status === '2xx') { statusMin = 200; statusMax = 299 }
18
+ else if (status === '3xx') { statusMin = 300; statusMax = 399 }
19
+ else if (status === '4xx') { statusMin = 400; statusMax = 499 }
20
+ else if (status === '5xx') { statusMin = 500; statusMax = 599 }
21
+
22
+ // Fetch all matching entries (use searchEntries for filtered queries)
23
+ const hasFilters = method || status || search
24
+ let entries: any[]
25
+ let total: number
26
+
27
+ if (hasFilters) {
28
+ // When using searchEntries, we need to post-filter since it uses LIKE on content JSON
29
+ const allEntries = await store.getEntries({ type: 'request', limit: 5000 })
30
+
31
+ // Apply in-memory filters for precise matching
32
+ const filtered = allEntries.filter((entry) => {
33
+ const c = JSON.parse(entry.content) as RequestEntryContent
34
+ if (method && c.method !== method) return false
35
+ if (statusMin !== undefined && (c.status < statusMin || c.status > statusMax!)) return false
36
+ if (search && !c.path.toLowerCase().includes(search.toLowerCase())) return false
37
+ return true
38
+ })
39
+
40
+ total = filtered.length
41
+ entries = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
42
+ } else {
43
+ total = await store.countEntries('request')
44
+ entries = await store.getEntries({ type: 'request', limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
45
+ }
8
46
 
9
47
  const rows = entries.map((entry) => {
10
48
  const c = JSON.parse(entry.content) as RequestEntryContent
@@ -19,11 +57,60 @@ export async function renderRequestsPage(store: HeartbeatStore, basePath: string
19
57
  ]
20
58
  })
21
59
 
60
+ // Build extra params for pagination links
61
+ const extraParams: Record<string, string> = {}
62
+ if (method) extraParams['method'] = method
63
+ if (status) extraParams['status'] = status
64
+ if (search) extraParams['search'] = search
65
+
66
+ const filters = filterBar({
67
+ action: `${basePath}/requests`,
68
+ searchPlaceholder: 'Search path...',
69
+ searchValue: search,
70
+ filters: [
71
+ {
72
+ name: 'method',
73
+ label: 'Method',
74
+ options: [
75
+ { value: 'GET', label: 'GET' },
76
+ { value: 'POST', label: 'POST' },
77
+ { value: 'PUT', label: 'PUT' },
78
+ { value: 'PATCH', label: 'PATCH' },
79
+ { value: 'DELETE', label: 'DELETE' },
80
+ ],
81
+ selected: method,
82
+ },
83
+ {
84
+ name: 'status',
85
+ label: 'Status',
86
+ options: [
87
+ { value: '2xx', label: '2xx Success' },
88
+ { value: '3xx', label: '3xx Redirect' },
89
+ { value: '4xx', label: '4xx Client Error' },
90
+ { value: '5xx', label: '5xx Server Error' },
91
+ ],
92
+ selected: status,
93
+ },
94
+ ],
95
+ })
96
+
97
+ // Build pagination URL base
98
+ const paginationBase = buildPaginationUrl(`${basePath}/requests`, extraParams)
99
+
22
100
  const content = `
101
+ ${filters}
23
102
  <div class="card">
24
103
  ${table(['Method', 'Path', 'Status', 'Duration', 'IP', 'Time'], rows)}
25
104
  </div>
105
+ ${pagination(total, page, PER_PAGE, paginationBase)}
26
106
  `
27
107
 
28
108
  return renderLayout({ title: 'Requests', activePage: 'requests', basePath, content })
29
109
  }
110
+
111
+ function buildPaginationUrl(base: string, params: Record<string, string>): string {
112
+ const entries = Object.entries(params).filter(([, v]) => v)
113
+ if (entries.length === 0) return base
114
+ const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
115
+ return `${base}?${qs}`
116
+ }
@@ -0,0 +1,71 @@
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 { ScheduleEntryContent } from '../../contracts/Entry.ts'
5
+
6
+ const PER_PAGE = 50
7
+
8
+ export async function renderSchedulesPage(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('schedule')
12
+ const entries = await store.getEntries({ type: 'schedule', limit: PER_PAGE, offset: (page - 1) * PER_PAGE })
13
+
14
+ let successCount = 0, errorCount = 0
15
+ for (const e of entries) {
16
+ const s = (JSON.parse(e.content) as ScheduleEntryContent).status
17
+ if (s === 'success') successCount++
18
+ else errorCount++
19
+ }
20
+
21
+ const rows = entries.map((entry) => {
22
+ const c = JSON.parse(entry.content) as ScheduleEntryContent
23
+ return [
24
+ `<span class="mono sm">${escapeHtml(c.command)}</span>`,
25
+ `<span class="mono sm muted">${escapeHtml(c.expression)}</span>`,
26
+ durationBadge(c.duration),
27
+ badge(c.status, c.status === 'success' ? 'green' : 'red'),
28
+ c.output ? `<span class="sm trunc muted" title="${escapeHtml(c.output)}">${escapeHtml(truncate(c.output, 60))}</span>` : '<span class="dim">--</span>',
29
+ `<span class="sm dim">${timeAgo(entry.created_at)}</span>`,
30
+ ]
31
+ })
32
+
33
+ const totalPages = Math.ceil(total / PER_PAGE)
34
+
35
+ const content = `
36
+ <div class="stats">
37
+ ${stat('Total Runs', total.toString())}
38
+ ${stat('Success', successCount.toString())}
39
+ ${stat('Errors', errorCount.toString())}
40
+ </div>
41
+
42
+ <div class="card">
43
+ ${table(['Command', 'Expression', 'Duration', 'Status', 'Output', 'Time'], rows)}
44
+ </div>
45
+
46
+ ${pagination(basePath + '/schedules', page, totalPages, searchParams)}
47
+ `
48
+
49
+ return renderLayout({ title: 'Schedules', activePage: 'schedules', basePath, content })
50
+ }
51
+
52
+ function pagination(baseUrl: string, current: number, total: number, searchParams?: URLSearchParams): string {
53
+ if (total <= 1) return ''
54
+
55
+ function buildUrl(p: number): string {
56
+ const params = new URLSearchParams(searchParams?.toString() ?? '')
57
+ params.set('page', String(p))
58
+ return `${baseUrl}?${params.toString()}`
59
+ }
60
+
61
+ const items: string[] = []
62
+ if (current > 1) {
63
+ 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>`)
64
+ }
65
+ items.push(`<span class="sm dim">Page ${current} of ${total}</span>`)
66
+ if (current < total) {
67
+ 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>`)
68
+ }
69
+
70
+ return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
71
+ }