@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
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
if (c.
|
|
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
|
|
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',
|
|
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">← <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: '⚡',
|
|
317
|
+
cache: '📦',
|
|
318
|
+
event: '⚡',
|
|
319
|
+
exception: '🔴',
|
|
320
|
+
log: '📝',
|
|
321
|
+
job: '⚙',
|
|
322
|
+
model: '🔷',
|
|
323
|
+
mail: '✉',
|
|
324
|
+
schedule: '⏰',
|
|
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] ?? '•'
|
|
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">✓</span>'
|
|
455
|
+
: isMiss
|
|
456
|
+
? '<span style="color:#f87171;font-weight:700" title="Cache Miss">✗</span>'
|
|
457
|
+
: ''
|
|
458
|
+
summary = `${indicator} ${op} → ${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
|
-
|
|
7
|
-
|
|
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">← 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 →</a>`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
|
|
71
|
+
}
|