@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.
- 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/metrics/MetricsCollector.ts +7 -3
- 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
package/package.json
CHANGED
package/src/Heartbeat.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HeartbeatConfig } from './contracts/HeartbeatConfig.ts'
|
|
2
|
-
import type { EntryType, PendingEntry } from './contracts/Entry.ts'
|
|
2
|
+
import type { EntryType, PendingEntry, OriginType } from './contracts/Entry.ts'
|
|
3
3
|
import type { Watcher } from './contracts/Watcher.ts'
|
|
4
4
|
import { HeartbeatStore } from './storage/HeartbeatStore.ts'
|
|
5
5
|
import { RecordHeartbeatEntries } from './jobs/RecordHeartbeatEntries.ts'
|
|
@@ -52,6 +52,11 @@ export class Heartbeat {
|
|
|
52
52
|
return this.watchers
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Count buffered entries of a given type (for widget stats before flush). */
|
|
56
|
+
getBufferedCount(type: EntryType): number {
|
|
57
|
+
return this.buffer.filter((e) => e.type === type).length
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
// ── Entry Recording ─────────────────────────────────────────────────────
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -69,6 +74,8 @@ export class Heartbeat {
|
|
|
69
74
|
content,
|
|
70
75
|
tags,
|
|
71
76
|
requestId: this._tracer?.currentRequestId() ?? null,
|
|
77
|
+
originType: this._tracer?.currentOriginType() ?? 'standalone',
|
|
78
|
+
originId: this._tracer?.currentRequestId() ?? null,
|
|
72
79
|
createdAt: Date.now(),
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -94,33 +101,14 @@ export class Heartbeat {
|
|
|
94
101
|
this.flushTimer = null
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
} catch {
|
|
104
|
-
// Queue not available
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (queueAvailable) {
|
|
108
|
-
try {
|
|
109
|
-
const { dispatch } = require('@mantiq/queue')
|
|
110
|
-
dispatch(new RecordHeartbeatEntries(entries))
|
|
111
|
-
.onQueue(this.config.queue.queue)
|
|
112
|
-
.onConnection(this.config.queue.connection)
|
|
113
|
-
.send()
|
|
114
|
-
.catch(() => {
|
|
115
|
-
this.store.insertEntries(entries).catch(() => { /* swallow */ })
|
|
116
|
-
})
|
|
117
|
-
} catch {
|
|
118
|
-
this.store.insertEntries(entries).catch(() => { /* swallow */ })
|
|
104
|
+
// Write directly to the dedicated heartbeat SQLite database.
|
|
105
|
+
// The queue adds unnecessary latency for telemetry — heartbeat has its
|
|
106
|
+
// own isolated connection so writes don't block the main app database.
|
|
107
|
+
this.store.insertEntries(entries).catch((e) => {
|
|
108
|
+
if (process.env.APP_DEBUG === 'true') {
|
|
109
|
+
console.error('[Heartbeat] Failed to persist entries:', (e as Error)?.message ?? e)
|
|
119
110
|
}
|
|
120
|
-
}
|
|
121
|
-
// Direct write — async fire-and-forget fallback
|
|
122
|
-
this.store.insertEntries(entries).catch(() => { /* swallow */ })
|
|
123
|
-
}
|
|
111
|
+
})
|
|
124
112
|
}
|
|
125
113
|
|
|
126
114
|
// ── Pruning ─────────────────────────────────────────────────────────────
|
|
@@ -21,6 +21,7 @@ import { ModelWatcher } from './watchers/ModelWatcher.ts'
|
|
|
21
21
|
import { LogWatcher } from './watchers/LogWatcher.ts'
|
|
22
22
|
import { MailWatcher } from './watchers/MailWatcher.ts'
|
|
23
23
|
import { ScheduleWatcher } from './watchers/ScheduleWatcher.ts'
|
|
24
|
+
import { CommandWatcher } from './watchers/CommandWatcher.ts'
|
|
24
25
|
import { CreateHeartbeatTables } from './migrations/CreateHeartbeatTables.ts'
|
|
25
26
|
import { DashboardController } from './dashboard/DashboardController.ts'
|
|
26
27
|
import { HeartbeatMiddleware } from './middleware/HeartbeatMiddleware.ts'
|
|
@@ -114,6 +115,9 @@ export class HeartbeatServiceProvider extends ServiceProvider {
|
|
|
114
115
|
this.registerMiddleware(heartbeat, tracer, requestWatcher, metrics)
|
|
115
116
|
} catch { /* HttpKernel not available */ }
|
|
116
117
|
|
|
118
|
+
// Wire external package hooks (CLI, logging, mail)
|
|
119
|
+
this.wireExternalWatchers(heartbeat, tracer)
|
|
120
|
+
|
|
117
121
|
// Register dashboard routes
|
|
118
122
|
if (config.dashboard.enabled) {
|
|
119
123
|
try {
|
|
@@ -220,6 +224,7 @@ export class HeartbeatServiceProvider extends ServiceProvider {
|
|
|
220
224
|
['log', new LogWatcher()],
|
|
221
225
|
['mail', new MailWatcher()],
|
|
222
226
|
['schedule', new ScheduleWatcher()],
|
|
227
|
+
['command', new CommandWatcher()],
|
|
223
228
|
]
|
|
224
229
|
|
|
225
230
|
const on = eventBus.on.bind(eventBus)
|
|
@@ -240,4 +245,65 @@ export class HeartbeatServiceProvider extends ServiceProvider {
|
|
|
240
245
|
|
|
241
246
|
return requestWatcher
|
|
242
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Wire the CLI command kernel to record command executions.
|
|
251
|
+
* Sets a static hook on @mantiq/cli Kernel so every command run
|
|
252
|
+
* creates a trace context and records a 'command' entry.
|
|
253
|
+
*/
|
|
254
|
+
private wireExternalWatchers(heartbeat: Heartbeat, tracer: Tracer): void {
|
|
255
|
+
// ── CLI commands ──────────────────────────────────────────────────────
|
|
256
|
+
try {
|
|
257
|
+
const { Kernel } = require('@mantiq/cli')
|
|
258
|
+
const commandWatcher = heartbeat.getWatchers().find((w) => w instanceof CommandWatcher) as CommandWatcher | undefined
|
|
259
|
+
if (commandWatcher) {
|
|
260
|
+
Kernel._onCommandExecuted = (data: { name: string; args: Record<string, any>; exitCode: number; duration: number }) => {
|
|
261
|
+
const commandId = crypto.randomUUID()
|
|
262
|
+
tracer.startCommand(commandId)
|
|
263
|
+
commandWatcher.recordCommand({
|
|
264
|
+
name: data.name,
|
|
265
|
+
arguments: data.args,
|
|
266
|
+
options: {},
|
|
267
|
+
exit_code: data.exitCode,
|
|
268
|
+
duration: data.duration,
|
|
269
|
+
output: null,
|
|
270
|
+
})
|
|
271
|
+
heartbeat.flush()
|
|
272
|
+
tracer.endCommand()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch { /* @mantiq/cli not installed */ }
|
|
276
|
+
|
|
277
|
+
// ── Logging ───────────────────────────────────────────────────────────
|
|
278
|
+
try {
|
|
279
|
+
const { LogManager } = require('@mantiq/logging')
|
|
280
|
+
const logWatcher = heartbeat.getWatchers().find((w) => w instanceof LogWatcher) as LogWatcher | undefined
|
|
281
|
+
if (logWatcher) {
|
|
282
|
+
LogManager._onLog = (level: string, message: string, context: Record<string, any>, channel: string) => {
|
|
283
|
+
logWatcher.recordLog(level, message, context, channel)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch { /* @mantiq/logging not installed */ }
|
|
287
|
+
|
|
288
|
+
// ── Mail ──────────────────────────────────────────────────────────────
|
|
289
|
+
try {
|
|
290
|
+
const { MailManager } = require('@mantiq/mail')
|
|
291
|
+
const mailWatcher = heartbeat.getWatchers().find((w) => w instanceof MailWatcher) as MailWatcher | undefined
|
|
292
|
+
if (mailWatcher) {
|
|
293
|
+
MailManager._onMailSent = (data: { to: string[]; subject: string; mailer: string; duration: number; queued: boolean }) => {
|
|
294
|
+
mailWatcher.recordMail({
|
|
295
|
+
to: data.to,
|
|
296
|
+
subject: data.subject,
|
|
297
|
+
from: '',
|
|
298
|
+
mailer: data.mailer,
|
|
299
|
+
html: null,
|
|
300
|
+
text: null,
|
|
301
|
+
attachments: [],
|
|
302
|
+
duration: data.duration,
|
|
303
|
+
queued: data.queued,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch { /* @mantiq/mail not installed */ }
|
|
308
|
+
}
|
|
243
309
|
}
|
package/src/contracts/Entry.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type EntryType =
|
|
|
12
12
|
| 'log'
|
|
13
13
|
| 'schedule'
|
|
14
14
|
| 'mail'
|
|
15
|
+
| 'command'
|
|
16
|
+
|
|
17
|
+
export type OriginType = 'request' | 'command' | 'schedule' | 'job' | 'standalone'
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* A raw pending entry before it's persisted — pushed by watchers into the buffer.
|
|
@@ -21,6 +24,8 @@ export interface PendingEntry {
|
|
|
21
24
|
content: Record<string, any>
|
|
22
25
|
tags?: string[] | undefined
|
|
23
26
|
requestId: string | null
|
|
27
|
+
originType: OriginType
|
|
28
|
+
originId: string | null
|
|
24
29
|
createdAt: number
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -32,6 +37,8 @@ export interface HeartbeatEntry {
|
|
|
32
37
|
uuid: string
|
|
33
38
|
type: EntryType
|
|
34
39
|
request_id: string | null
|
|
40
|
+
origin_type: string
|
|
41
|
+
origin_id: string | null
|
|
35
42
|
content: string
|
|
36
43
|
tags: string
|
|
37
44
|
created_at: number
|
|
@@ -103,6 +110,8 @@ export interface JobEntryContent {
|
|
|
103
110
|
export interface EventEntryContent {
|
|
104
111
|
event_class: string
|
|
105
112
|
listeners_count: number
|
|
113
|
+
payload: Record<string, any> | null
|
|
114
|
+
listeners: string[]
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
export interface ModelEntryContent {
|
|
@@ -124,6 +133,16 @@ export interface ScheduleEntryContent {
|
|
|
124
133
|
expression: string
|
|
125
134
|
duration: number
|
|
126
135
|
status: 'success' | 'error'
|
|
136
|
+
output: string | null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CommandEntryContent {
|
|
140
|
+
name: string
|
|
141
|
+
arguments: Record<string, any>
|
|
142
|
+
options: Record<string, any>
|
|
143
|
+
exit_code: number
|
|
144
|
+
duration: number
|
|
145
|
+
output: string | null
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
export interface MailEntryContent {
|
|
@@ -24,6 +24,7 @@ export interface HeartbeatConfig {
|
|
|
24
24
|
log: { enabled: boolean; level: string }
|
|
25
25
|
schedule: { enabled: boolean }
|
|
26
26
|
mail: { enabled: boolean }
|
|
27
|
+
command: { enabled: boolean }
|
|
27
28
|
}
|
|
28
29
|
tracing: { enabled: boolean; propagate: boolean }
|
|
29
30
|
sampling: { rate: number; always_sample_errors: boolean }
|
|
@@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: HeartbeatConfig = {
|
|
|
57
58
|
log: { enabled: true, level: 'debug' },
|
|
58
59
|
schedule: { enabled: true },
|
|
59
60
|
mail: { enabled: true },
|
|
61
|
+
command: { enabled: true },
|
|
60
62
|
},
|
|
61
63
|
tracing: { enabled: true, propagate: true },
|
|
62
64
|
sampling: { rate: 1.0, always_sample_errors: true },
|
|
@@ -11,6 +11,12 @@ import { renderPerformancePage } from './pages/PerformancePage.ts'
|
|
|
11
11
|
import { renderRequestDetailPage } from './pages/RequestDetailPage.ts'
|
|
12
12
|
import { renderMailPage } from './pages/MailPage.ts'
|
|
13
13
|
import { renderMailDetailPage } from './pages/MailDetailPage.ts'
|
|
14
|
+
import { renderLogsPage } from './pages/LogsPage.ts'
|
|
15
|
+
import { renderModelsPage } from './pages/ModelsPage.ts'
|
|
16
|
+
import { renderSchedulesPage } from './pages/SchedulesPage.ts'
|
|
17
|
+
import { renderCommandsPage } from './pages/CommandsPage.ts'
|
|
18
|
+
import { renderCommandDetailPage } from './pages/CommandDetailPage.ts'
|
|
19
|
+
import { renderNotificationsPage } from './pages/NotificationsPage.ts'
|
|
14
20
|
import type { EntryType } from '../contracts/Entry.ts'
|
|
15
21
|
|
|
16
22
|
/**
|
|
@@ -40,39 +46,49 @@ export class DashboardController {
|
|
|
40
46
|
|
|
41
47
|
// API endpoints
|
|
42
48
|
if (sub.startsWith('api/')) {
|
|
43
|
-
return this.handleApi(sub.slice(4), url.searchParams)
|
|
49
|
+
return this.handleApi(sub.slice(4), url.searchParams, request.method)
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
// Page routes
|
|
47
|
-
const html = await this.renderPage(sub)
|
|
53
|
+
const html = await this.renderPage(sub, url.searchParams)
|
|
48
54
|
return new Response(html, {
|
|
49
55
|
status: 200,
|
|
50
56
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
51
57
|
})
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
private async renderPage(sub: string): Promise<string> {
|
|
60
|
+
private async renderPage(sub: string, searchParams?: URLSearchParams): Promise<string> {
|
|
55
61
|
// Static page routes
|
|
56
62
|
switch (sub) {
|
|
57
63
|
case '':
|
|
58
64
|
case 'overview':
|
|
59
65
|
return renderOverviewPage(this.store, this.metrics, this.basePath)
|
|
60
66
|
case 'requests':
|
|
61
|
-
return renderRequestsPage(this.store, this.basePath)
|
|
67
|
+
return renderRequestsPage(this.store, this.basePath, searchParams)
|
|
62
68
|
case 'queries':
|
|
63
|
-
return renderQueriesPage(this.store, this.basePath)
|
|
69
|
+
return renderQueriesPage(this.store, this.basePath, searchParams)
|
|
64
70
|
case 'exceptions':
|
|
65
|
-
return renderExceptionsPage(this.store, this.basePath)
|
|
71
|
+
return renderExceptionsPage(this.store, this.basePath, searchParams)
|
|
66
72
|
case 'jobs':
|
|
67
|
-
return renderJobsPage(this.store, this.basePath)
|
|
73
|
+
return renderJobsPage(this.store, this.basePath, searchParams)
|
|
68
74
|
case 'cache':
|
|
69
|
-
return renderCachePage(this.store, this.basePath)
|
|
75
|
+
return renderCachePage(this.store, this.basePath, searchParams)
|
|
70
76
|
case 'events':
|
|
71
|
-
return renderEventsPage(this.store, this.basePath)
|
|
77
|
+
return renderEventsPage(this.store, this.basePath, searchParams)
|
|
72
78
|
case 'performance':
|
|
73
|
-
return renderPerformancePage(this.metrics, this.basePath)
|
|
79
|
+
return renderPerformancePage(this.store, this.metrics, this.basePath, searchParams)
|
|
74
80
|
case 'mail':
|
|
75
81
|
return renderMailPage(this.store, this.basePath)
|
|
82
|
+
case 'logs':
|
|
83
|
+
return renderLogsPage(this.store, this.basePath, searchParams)
|
|
84
|
+
case 'models':
|
|
85
|
+
return renderModelsPage(this.store, this.basePath, searchParams)
|
|
86
|
+
case 'schedules':
|
|
87
|
+
return renderSchedulesPage(this.store, this.basePath, searchParams)
|
|
88
|
+
case 'commands':
|
|
89
|
+
return renderCommandsPage(this.store, this.basePath, searchParams)
|
|
90
|
+
case 'notifications':
|
|
91
|
+
return renderNotificationsPage(this.store, this.basePath, searchParams)
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
// Parameterized routes
|
|
@@ -88,12 +104,18 @@ export class DashboardController {
|
|
|
88
104
|
return html ?? this.render404()
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
const commandDetail = sub.match(/^commands\/([a-f0-9-]+)$/)
|
|
108
|
+
if (commandDetail) {
|
|
109
|
+
const html = await renderCommandDetailPage(this.store, commandDetail[1]!, this.basePath)
|
|
110
|
+
return html ?? this.render404()
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
return this.render404()
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
// ── API Endpoints ─────────────────────────────────────────────────────
|
|
95
117
|
|
|
96
|
-
private async handleApi(sub: string, params: URLSearchParams): Promise<Response> {
|
|
118
|
+
private async handleApi(sub: string, params: URLSearchParams, method: string): Promise<Response> {
|
|
97
119
|
try {
|
|
98
120
|
switch (sub) {
|
|
99
121
|
case 'entries':
|
|
@@ -102,8 +124,31 @@ export class DashboardController {
|
|
|
102
124
|
return this.apiMetrics()
|
|
103
125
|
case 'exception-groups':
|
|
104
126
|
return this.apiExceptionGroups()
|
|
105
|
-
|
|
127
|
+
case 'exception-groups/resolve':
|
|
128
|
+
if (method === 'POST') return this.apiResolveExceptionGroup(params)
|
|
129
|
+
return Response.json({ error: 'Method not allowed' }, { status: 405 })
|
|
130
|
+
case 'exception-groups/unresolve':
|
|
131
|
+
if (method === 'POST') return this.apiUnresolveExceptionGroup(params)
|
|
132
|
+
return Response.json({ error: 'Method not allowed' }, { status: 405 })
|
|
133
|
+
default: {
|
|
134
|
+
// Handle parameterized API routes: exceptions/{fingerprint}/resolve|unresolve
|
|
135
|
+
const exceptionAction = sub.match(/^exceptions\/([^/]+)\/(resolve|unresolve)$/)
|
|
136
|
+
if (exceptionAction) {
|
|
137
|
+
const fingerprint = decodeURIComponent(exceptionAction[1]!)
|
|
138
|
+
const action = exceptionAction[2]!
|
|
139
|
+
if (action === 'resolve') {
|
|
140
|
+
await this.store.resolveExceptionGroup(fingerprint)
|
|
141
|
+
} else {
|
|
142
|
+
await this.store.unresolveExceptionGroup(fingerprint)
|
|
143
|
+
}
|
|
144
|
+
// Redirect back to exceptions page
|
|
145
|
+
return new Response(null, {
|
|
146
|
+
status: 302,
|
|
147
|
+
headers: { Location: `${this.basePath}/exceptions` },
|
|
148
|
+
})
|
|
149
|
+
}
|
|
106
150
|
return Response.json({ error: 'Not found' }, { status: 404 })
|
|
151
|
+
}
|
|
107
152
|
}
|
|
108
153
|
} catch (error) {
|
|
109
154
|
return Response.json({ error: 'Internal error' }, { status: 500 })
|
|
@@ -161,6 +206,20 @@ export class DashboardController {
|
|
|
161
206
|
return Response.json({ data: groups })
|
|
162
207
|
}
|
|
163
208
|
|
|
209
|
+
private async apiResolveExceptionGroup(params: URLSearchParams): Promise<Response> {
|
|
210
|
+
const fingerprint = params.get('fingerprint')
|
|
211
|
+
if (!fingerprint) return Response.json({ error: 'Missing fingerprint' }, { status: 400 })
|
|
212
|
+
await this.store.resolveExceptionGroup(fingerprint)
|
|
213
|
+
return Response.json({ success: true })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async apiUnresolveExceptionGroup(params: URLSearchParams): Promise<Response> {
|
|
217
|
+
const fingerprint = params.get('fingerprint')
|
|
218
|
+
if (!fingerprint) return Response.json({ error: 'Missing fingerprint' }, { status: 400 })
|
|
219
|
+
await this.store.unresolveExceptionGroup(fingerprint)
|
|
220
|
+
return Response.json({ success: true })
|
|
221
|
+
}
|
|
222
|
+
|
|
164
223
|
private render404(): string {
|
|
165
224
|
return `<!DOCTYPE html><html><body><h1>404 — Page not found</h1><p><a href="${this.basePath}">Back to Heartbeat</a></p></body></html>`
|
|
166
225
|
}
|
|
@@ -1,13 +1,31 @@
|
|
|
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, filterBar, pagination } from '../shared/components.ts'
|
|
3
3
|
import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
|
|
4
4
|
import type { CacheEntryContent } from '../../contracts/Entry.ts'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
const entries = await store.getEntries({ type: 'cache', limit: 100 })
|
|
6
|
+
const PER_PAGE = 50
|
|
8
7
|
|
|
8
|
+
export async function renderCachePage(store: HeartbeatStore, basePath: string, searchParams?: URLSearchParams): Promise<string> {
|
|
9
|
+
const page = parseInt(searchParams?.get('page') ?? '1')
|
|
10
|
+
const operationFilter = searchParams?.get('operation') ?? ''
|
|
11
|
+
const keySearch = searchParams?.get('search') ?? ''
|
|
12
|
+
|
|
13
|
+
// Fetch all cache entries for stats + filtering
|
|
14
|
+
const allEntries = await store.getEntries({ type: 'cache', limit: 5000 })
|
|
15
|
+
|
|
16
|
+
// Apply filters in-memory
|
|
17
|
+
const filtered = allEntries.filter((e) => {
|
|
18
|
+
const c = JSON.parse(e.content) as CacheEntryContent
|
|
19
|
+
if (operationFilter && c.operation !== operationFilter) return false
|
|
20
|
+
if (keySearch && !c.key.toLowerCase().includes(keySearch.toLowerCase())) return false
|
|
21
|
+
return true
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const total = filtered.length
|
|
25
|
+
|
|
26
|
+
// Compute stats from all entries (not just filtered) for overall hit rate
|
|
9
27
|
let hits = 0, misses = 0, writes = 0, forgets = 0
|
|
10
|
-
for (const e of
|
|
28
|
+
for (const e of allEntries) {
|
|
11
29
|
const op = (JSON.parse(e.content) as CacheEntryContent).operation
|
|
12
30
|
if (op === 'hit') hits++
|
|
13
31
|
else if (op === 'miss') misses++
|
|
@@ -17,7 +35,10 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
|
|
|
17
35
|
|
|
18
36
|
const hitRate = hits + misses > 0 ? ((hits / (hits + misses)) * 100).toFixed(0) + '%' : '--'
|
|
19
37
|
|
|
20
|
-
|
|
38
|
+
// Paginate filtered entries
|
|
39
|
+
const pageEntries = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
|
40
|
+
|
|
41
|
+
const rows = pageEntries.map((entry) => {
|
|
21
42
|
const c = JSON.parse(entry.content) as CacheEntryContent
|
|
22
43
|
const v = c.operation === 'hit' ? 'green' : c.operation === 'miss' ? 'amber' : c.operation === 'write' ? 'blue' : 'mute'
|
|
23
44
|
return [
|
|
@@ -28,7 +49,34 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
|
|
|
28
49
|
]
|
|
29
50
|
})
|
|
30
51
|
|
|
52
|
+
// Build extra params for pagination
|
|
53
|
+
const extraParams: Record<string, string> = {}
|
|
54
|
+
if (operationFilter) extraParams['operation'] = operationFilter
|
|
55
|
+
if (keySearch) extraParams['search'] = keySearch
|
|
56
|
+
|
|
57
|
+
const filters = filterBar({
|
|
58
|
+
action: `${basePath}/cache`,
|
|
59
|
+
searchPlaceholder: 'Search key...',
|
|
60
|
+
searchValue: keySearch,
|
|
61
|
+
filters: [
|
|
62
|
+
{
|
|
63
|
+
name: 'operation',
|
|
64
|
+
label: 'Operation',
|
|
65
|
+
options: [
|
|
66
|
+
{ value: 'hit', label: 'Hit' },
|
|
67
|
+
{ value: 'miss', label: 'Miss' },
|
|
68
|
+
{ value: 'write', label: 'Write' },
|
|
69
|
+
{ value: 'forget', label: 'Forget' },
|
|
70
|
+
],
|
|
71
|
+
selected: operationFilter,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const paginationBase = buildPaginationUrl(`${basePath}/cache`, extraParams)
|
|
77
|
+
|
|
31
78
|
const content = `
|
|
79
|
+
${filters}
|
|
32
80
|
<div class="stats">
|
|
33
81
|
${stat('Hit Rate', hitRate, `${hits} hits / ${misses} misses`)}
|
|
34
82
|
${stat('Hits', hits.toString())}
|
|
@@ -38,7 +86,15 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
|
|
|
38
86
|
<div class="card">
|
|
39
87
|
${table(['Operation', 'Key', 'Store', 'Time'], rows)}
|
|
40
88
|
</div>
|
|
89
|
+
${pagination(total, page, PER_PAGE, paginationBase)}
|
|
41
90
|
`
|
|
42
91
|
|
|
43
92
|
return renderLayout({ title: 'Cache', activePage: 'cache', basePath, content })
|
|
44
93
|
}
|
|
94
|
+
|
|
95
|
+
function buildPaginationUrl(base: string, params: Record<string, string>): string {
|
|
96
|
+
const entries = Object.entries(params).filter(([, v]) => v)
|
|
97
|
+
if (entries.length === 0) return base
|
|
98
|
+
const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
|
99
|
+
return `${base}?${qs}`
|
|
100
|
+
}
|