@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.
- package/package.json +1 -1
- package/src/Heartbeat.ts +15 -27
- package/src/HeartbeatServiceProvider.ts +66 -0
- package/src/contracts/Entry.ts +19 -0
- package/src/contracts/HeartbeatConfig.ts +2 -0
- package/src/dashboard/DashboardController.ts +71 -12
- package/src/dashboard/pages/CachePage.ts +61 -5
- package/src/dashboard/pages/CommandDetailPage.ts +216 -0
- package/src/dashboard/pages/CommandsPage.ts +72 -0
- package/src/dashboard/pages/EventsPage.ts +59 -6
- package/src/dashboard/pages/ExceptionsPage.ts +37 -6
- package/src/dashboard/pages/JobsPage.ts +61 -5
- package/src/dashboard/pages/LogsPage.ts +116 -0
- package/src/dashboard/pages/ModelsPage.ts +112 -0
- package/src/dashboard/pages/NotificationsPage.ts +87 -0
- package/src/dashboard/pages/OverviewPage.ts +109 -45
- package/src/dashboard/pages/PerformancePage.ts +151 -20
- package/src/dashboard/pages/QueriesPage.ts +92 -8
- package/src/dashboard/pages/RequestDetailPage.ts +227 -3
- package/src/dashboard/pages/RequestsPage.ts +90 -3
- package/src/dashboard/pages/SchedulesPage.ts +71 -0
- package/src/dashboard/shared/components.ts +140 -0
- package/src/dashboard/shared/layout.ts +327 -108
- package/src/index.ts +9 -0
- package/src/middleware/HeartbeatMiddleware.ts +26 -9
- package/src/migrations/CreateHeartbeatTables.ts +14 -0
- package/src/models/EntryModel.ts +1 -1
- package/src/storage/HeartbeatStore.ts +174 -1
- package/src/testing/HeartbeatFake.ts +6 -0
- package/src/tracing/Tracer.ts +32 -0
- package/src/watchers/CommandWatcher.ts +23 -0
- package/src/watchers/EventWatcher.ts +13 -0
- package/src/watchers/ScheduleWatcher.ts +1 -0
- package/src/widget/DebugWidget.ts +53 -30
|
@@ -0,0 +1,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)">›</span>
|
|
54
|
+
<a href="${basePath}/commands" style="color:var(--fg-3);text-decoration:none">Commands</a>
|
|
55
|
+
<span style="color:var(--fg-3)">›</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">← 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 →</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
|
-
|
|
7
|
-
const entries = await store.getEntries({ type: 'event', limit: 100 })
|
|
7
|
+
const PER_PAGE = 50
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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.
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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">← 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 →</a>`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return `<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px">${items.join('')}</div>`
|
|
116
|
+
}
|