@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
|
@@ -95,16 +95,27 @@ export class HeartbeatMiddleware implements Middleware {
|
|
|
95
95
|
} finally {
|
|
96
96
|
const duration = performance.now() - startTime
|
|
97
97
|
|
|
98
|
-
if (this.tracer) {
|
|
99
|
-
this.tracer.endRequest()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
98
|
// Capture response data (must rebuild response so body stays available for upstream middleware)
|
|
99
|
+
// Record BEFORE ending the trace context so request_id is attached
|
|
103
100
|
if (this.requestWatcher && !error) {
|
|
104
101
|
const responseHeaders = this.captureResponseHeaders(response!)
|
|
105
102
|
const { body: responseBody, size: responseSize, rebuilt } = await this.captureResponseBody(response!)
|
|
106
103
|
if (rebuilt) response = rebuilt
|
|
107
104
|
|
|
105
|
+
// Try to extract route metadata
|
|
106
|
+
let middlewareList: string[] = []
|
|
107
|
+
let controllerName: string | null = null
|
|
108
|
+
let routeName: string | null = null
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const route = (request as any).route?.() ?? (request as any)._matchedRoute
|
|
112
|
+
if (route) {
|
|
113
|
+
middlewareList = route.middleware ?? route._middleware ?? []
|
|
114
|
+
controllerName = route.controller ?? route.action ?? null
|
|
115
|
+
routeName = route.name ?? null
|
|
116
|
+
}
|
|
117
|
+
} catch { /* route info not available */ }
|
|
118
|
+
|
|
108
119
|
this.requestWatcher.recordRequest({
|
|
109
120
|
method: request.method(),
|
|
110
121
|
path: request.path(),
|
|
@@ -112,9 +123,9 @@ export class HeartbeatMiddleware implements Middleware {
|
|
|
112
123
|
status: response!.status,
|
|
113
124
|
duration,
|
|
114
125
|
ip: request.ip(),
|
|
115
|
-
middleware:
|
|
116
|
-
controller:
|
|
117
|
-
routeName
|
|
126
|
+
middleware: middlewareList,
|
|
127
|
+
controller: controllerName,
|
|
128
|
+
routeName,
|
|
118
129
|
memoryUsage: process.memoryUsage().rss - startMemory,
|
|
119
130
|
requestHeaders,
|
|
120
131
|
requestQuery,
|
|
@@ -138,6 +149,11 @@ export class HeartbeatMiddleware implements Middleware {
|
|
|
138
149
|
|
|
139
150
|
// Flush entries (fire-and-forget)
|
|
140
151
|
this.heartbeat.flush()
|
|
152
|
+
|
|
153
|
+
// End the trace context AFTER recording — so all entries get the request_id
|
|
154
|
+
if (this.tracer) {
|
|
155
|
+
this.tracer.endRequest()
|
|
156
|
+
}
|
|
141
157
|
}
|
|
142
158
|
|
|
143
159
|
// Debug mode: attach X-Heartbeat header + inject widget
|
|
@@ -151,7 +167,8 @@ export class HeartbeatMiddleware implements Middleware {
|
|
|
151
167
|
const totalDuration = performance.now() - startTime
|
|
152
168
|
const totalMemory = Math.abs(process.memoryUsage().rss - startMemory)
|
|
153
169
|
const mem = (totalMemory / 1024 / 1024).toFixed(1)
|
|
154
|
-
const
|
|
170
|
+
const queryCount = this.heartbeat.getBufferedCount('query')
|
|
171
|
+
const statsHeader = `${Math.round(totalDuration)}ms;${mem}MB;${response!.status};${queryCount}q`
|
|
155
172
|
|
|
156
173
|
try {
|
|
157
174
|
const cloned = response!.clone()
|
|
@@ -167,7 +184,7 @@ export class HeartbeatMiddleware implements Middleware {
|
|
|
167
184
|
duration: totalDuration,
|
|
168
185
|
memory: totalMemory,
|
|
169
186
|
status: response!.status,
|
|
170
|
-
queries:
|
|
187
|
+
queries: queryCount,
|
|
171
188
|
dashboardPath: this.heartbeat.config.dashboard.path,
|
|
172
189
|
})
|
|
173
190
|
finalBody = body.replace('</body>', widget + '\n</body>')
|
|
@@ -10,6 +10,8 @@ export class CreateHeartbeatTables extends Migration {
|
|
|
10
10
|
table.uuid('uuid').unique()
|
|
11
11
|
table.string('type', 50)
|
|
12
12
|
table.string('request_id', 255).nullable()
|
|
13
|
+
table.string('origin_type', 20).default('standalone')
|
|
14
|
+
table.string('origin_id', 255).nullable()
|
|
13
15
|
table.text('content')
|
|
14
16
|
table.text('tags').default('[]')
|
|
15
17
|
table.bigInteger('created_at')
|
|
@@ -17,6 +19,7 @@ export class CreateHeartbeatTables extends Migration {
|
|
|
17
19
|
table.index('type')
|
|
18
20
|
table.index('request_id')
|
|
19
21
|
table.index(['type', 'created_at'])
|
|
22
|
+
table.index(['origin_type', 'origin_id'])
|
|
20
23
|
})
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -70,6 +73,17 @@ export class CreateHeartbeatTables extends Migration {
|
|
|
70
73
|
table.primary('fingerprint')
|
|
71
74
|
})
|
|
72
75
|
}
|
|
76
|
+
|
|
77
|
+
// Backfill origin columns for existing installations
|
|
78
|
+
if (await schema.hasTable('heartbeat_entries')) {
|
|
79
|
+
if (!(await schema.hasColumn('heartbeat_entries', 'origin_type'))) {
|
|
80
|
+
await schema.table('heartbeat_entries', (table) => {
|
|
81
|
+
table.string('origin_type', 20).default('standalone')
|
|
82
|
+
table.string('origin_id', 255).nullable()
|
|
83
|
+
table.index(['origin_type', 'origin_id'])
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
override async down(schema: SchemaBuilder, _db: DatabaseConnection): Promise<void> {
|
package/src/models/EntryModel.ts
CHANGED
|
@@ -5,7 +5,7 @@ export class EntryModel extends Model {
|
|
|
5
5
|
static override incrementing = true
|
|
6
6
|
static override keyType = 'int' as const
|
|
7
7
|
static override timestamps = false
|
|
8
|
-
static override fillable = ['uuid', 'type', 'request_id', 'content', 'tags', 'created_at']
|
|
8
|
+
static override fillable = ['uuid', 'type', 'request_id', 'origin_type', 'origin_id', 'content', 'tags', 'created_at']
|
|
9
9
|
static override casts: Record<string, 'int' | 'float' | 'boolean' | 'string' | 'json' | 'date' | 'datetime' | 'array'> = {
|
|
10
10
|
id: 'int',
|
|
11
11
|
created_at: 'int',
|
|
@@ -38,6 +38,8 @@ export class HeartbeatStore {
|
|
|
38
38
|
uuid: crypto.randomUUID(),
|
|
39
39
|
type: entry.type,
|
|
40
40
|
request_id: entry.requestId,
|
|
41
|
+
origin_type: entry.originType ?? 'standalone',
|
|
42
|
+
origin_id: entry.originId ?? null,
|
|
41
43
|
content: JSON.stringify(entry.content),
|
|
42
44
|
tags: JSON.stringify(entry.tags ?? []),
|
|
43
45
|
created_at: entry.createdAt,
|
|
@@ -58,8 +60,10 @@ export class HeartbeatStore {
|
|
|
58
60
|
limit?: number | undefined
|
|
59
61
|
offset?: number | undefined
|
|
60
62
|
requestId?: string | undefined
|
|
63
|
+
originType?: string | undefined
|
|
64
|
+
originId?: string | undefined
|
|
61
65
|
} = {}): Promise<HeartbeatEntry[]> {
|
|
62
|
-
const { type, limit = 50, offset = 0, requestId } = options
|
|
66
|
+
const { type, limit = 50, offset = 0, requestId, originType, originId } = options
|
|
63
67
|
|
|
64
68
|
let query = EntryModel.query<EntryModel>()
|
|
65
69
|
|
|
@@ -69,6 +73,12 @@ export class HeartbeatStore {
|
|
|
69
73
|
if (requestId) {
|
|
70
74
|
query = query.where('request_id', requestId)
|
|
71
75
|
}
|
|
76
|
+
if (originType) {
|
|
77
|
+
query = query.where('origin_type', originType)
|
|
78
|
+
}
|
|
79
|
+
if (originId) {
|
|
80
|
+
query = query.where('origin_id', originId)
|
|
81
|
+
}
|
|
72
82
|
|
|
73
83
|
const results = await query
|
|
74
84
|
.orderBy('created_at', 'desc')
|
|
@@ -97,6 +107,169 @@ export class HeartbeatStore {
|
|
|
97
107
|
return EntryModel.count<EntryModel>()
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Search entries with full-text query, type/origin filters, and pagination.
|
|
112
|
+
*/
|
|
113
|
+
async searchEntries(options: {
|
|
114
|
+
type?: EntryType
|
|
115
|
+
query?: string
|
|
116
|
+
method?: string
|
|
117
|
+
statusMin?: number
|
|
118
|
+
statusMax?: number
|
|
119
|
+
since?: number
|
|
120
|
+
until?: number
|
|
121
|
+
originType?: string
|
|
122
|
+
limit?: number
|
|
123
|
+
offset?: number
|
|
124
|
+
} = {}): Promise<{ entries: HeartbeatEntry[]; total: number }> {
|
|
125
|
+
const { type, query: q, method, statusMin, statusMax, since, until, originType, limit = 50, offset = 0 } = options
|
|
126
|
+
|
|
127
|
+
let baseQuery = this.connection.table('heartbeat_entries')
|
|
128
|
+
|
|
129
|
+
if (type) baseQuery = baseQuery.where('type', type)
|
|
130
|
+
if (originType) baseQuery = baseQuery.where('origin_type', originType)
|
|
131
|
+
if (since) baseQuery = baseQuery.where('created_at', '>=', since)
|
|
132
|
+
if (until) baseQuery = baseQuery.where('created_at', '<=', until)
|
|
133
|
+
if (q) baseQuery = baseQuery.where('content', 'LIKE', `%${q}%`)
|
|
134
|
+
if (method) baseQuery = baseQuery.where('content', 'LIKE', `%"method":"${method}"%`)
|
|
135
|
+
if (statusMin !== undefined) baseQuery = baseQuery.where('content', 'LIKE', `%"status":${statusMin}%`)
|
|
136
|
+
if (statusMax !== undefined) baseQuery = baseQuery.where('content', 'LIKE', `%"status":${statusMax}%`)
|
|
137
|
+
|
|
138
|
+
const countResult = await baseQuery.count()
|
|
139
|
+
const total = typeof countResult === 'number' ? countResult : 0
|
|
140
|
+
|
|
141
|
+
// Rebuild query for results (count may consume the builder)
|
|
142
|
+
let resultsQuery = this.connection.table('heartbeat_entries')
|
|
143
|
+
if (type) resultsQuery = resultsQuery.where('type', type)
|
|
144
|
+
if (originType) resultsQuery = resultsQuery.where('origin_type', originType)
|
|
145
|
+
if (since) resultsQuery = resultsQuery.where('created_at', '>=', since)
|
|
146
|
+
if (until) resultsQuery = resultsQuery.where('created_at', '<=', until)
|
|
147
|
+
if (q) resultsQuery = resultsQuery.where('content', 'LIKE', `%${q}%`)
|
|
148
|
+
if (method) resultsQuery = resultsQuery.where('content', 'LIKE', `%"method":"${method}"%`)
|
|
149
|
+
if (statusMin !== undefined) resultsQuery = resultsQuery.where('content', 'LIKE', `%"status":${statusMin}%`)
|
|
150
|
+
if (statusMax !== undefined) resultsQuery = resultsQuery.where('content', 'LIKE', `%"status":${statusMax}%`)
|
|
151
|
+
|
|
152
|
+
const rows = await resultsQuery
|
|
153
|
+
.orderBy('created_at', 'desc')
|
|
154
|
+
.limit(limit)
|
|
155
|
+
.offset(offset)
|
|
156
|
+
.get()
|
|
157
|
+
|
|
158
|
+
return { entries: rows as unknown as HeartbeatEntry[], total }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get time-bucketed entry counts for sparkline/histogram charts.
|
|
163
|
+
* Returns an array of `count` buckets going back from now.
|
|
164
|
+
*/
|
|
165
|
+
async getTimeBuckets(type: EntryType, bucketMs: number, count: number): Promise<number[]> {
|
|
166
|
+
const now = Date.now()
|
|
167
|
+
const since = now - bucketMs * count
|
|
168
|
+
const rows = await this.connection.table('heartbeat_entries')
|
|
169
|
+
.where('type', type)
|
|
170
|
+
.where('created_at', '>=', since)
|
|
171
|
+
.select('created_at')
|
|
172
|
+
.get()
|
|
173
|
+
|
|
174
|
+
const buckets = new Array<number>(count).fill(0)
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
const ts = row.created_at as number
|
|
177
|
+
const idx = Math.floor((ts - since) / bucketMs)
|
|
178
|
+
if (idx >= 0 && idx < count) {
|
|
179
|
+
buckets[idx]!++
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return buckets
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the slowest endpoints ranked by average duration.
|
|
188
|
+
*/
|
|
189
|
+
async getTopSlowEndpoints(limit: number, since: number): Promise<Array<{ path: string; avg_duration: number; max_duration: number; count: number }>> {
|
|
190
|
+
const rows = await this.connection.table('heartbeat_entries')
|
|
191
|
+
.where('type', 'request')
|
|
192
|
+
.where('created_at', '>=', since)
|
|
193
|
+
.select('content')
|
|
194
|
+
.get()
|
|
195
|
+
|
|
196
|
+
const map = new Map<string, { total: number; max: number; count: number }>()
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
try {
|
|
199
|
+
const content = typeof row.content === 'string' ? JSON.parse(row.content) : row.content
|
|
200
|
+
const path = content.path as string
|
|
201
|
+
const duration = content.duration as number
|
|
202
|
+
if (!path || typeof duration !== 'number') continue
|
|
203
|
+
|
|
204
|
+
const existing = map.get(path)
|
|
205
|
+
if (existing) {
|
|
206
|
+
existing.total += duration
|
|
207
|
+
existing.max = Math.max(existing.max, duration)
|
|
208
|
+
existing.count++
|
|
209
|
+
} else {
|
|
210
|
+
map.set(path, { total: duration, max: duration, count: 1 })
|
|
211
|
+
}
|
|
212
|
+
} catch { /* skip malformed content */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return Array.from(map.entries())
|
|
216
|
+
.map(([path, stats]) => ({
|
|
217
|
+
path,
|
|
218
|
+
avg_duration: stats.total / stats.count,
|
|
219
|
+
max_duration: stats.max,
|
|
220
|
+
count: stats.count,
|
|
221
|
+
}))
|
|
222
|
+
.sort((a, b) => b.avg_duration - a.avg_duration)
|
|
223
|
+
.slice(0, limit)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get the most frequently executed queries ranked by count.
|
|
228
|
+
*/
|
|
229
|
+
async getTopFrequentQueries(limit: number, since: number): Promise<Array<{ sql: string; count: number; avg_duration: number }>> {
|
|
230
|
+
const rows = await this.connection.table('heartbeat_entries')
|
|
231
|
+
.where('type', 'query')
|
|
232
|
+
.where('created_at', '>=', since)
|
|
233
|
+
.select('content')
|
|
234
|
+
.get()
|
|
235
|
+
|
|
236
|
+
const map = new Map<string, { total: number; count: number }>()
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
try {
|
|
239
|
+
const content = typeof row.content === 'string' ? JSON.parse(row.content) : row.content
|
|
240
|
+
const sql = (content.normalized_sql ?? content.sql) as string
|
|
241
|
+
const duration = content.duration as number
|
|
242
|
+
if (!sql) continue
|
|
243
|
+
|
|
244
|
+
const existing = map.get(sql)
|
|
245
|
+
if (existing) {
|
|
246
|
+
existing.total += typeof duration === 'number' ? duration : 0
|
|
247
|
+
existing.count++
|
|
248
|
+
} else {
|
|
249
|
+
map.set(sql, { total: typeof duration === 'number' ? duration : 0, count: 1 })
|
|
250
|
+
}
|
|
251
|
+
} catch { /* skip malformed content */ }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return Array.from(map.entries())
|
|
255
|
+
.map(([sql, stats]) => ({
|
|
256
|
+
sql,
|
|
257
|
+
count: stats.count,
|
|
258
|
+
avg_duration: stats.count > 0 ? stats.total / stats.count : 0,
|
|
259
|
+
}))
|
|
260
|
+
.sort((a, b) => b.count - a.count)
|
|
261
|
+
.slice(0, limit)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Unresolve an exception group by clearing its resolved_at timestamp.
|
|
266
|
+
*/
|
|
267
|
+
async unresolveExceptionGroup(fingerprint: string): Promise<void> {
|
|
268
|
+
await this.connection.table('heartbeat_exception_groups')
|
|
269
|
+
.where('fingerprint', fingerprint)
|
|
270
|
+
.update({ resolved_at: null })
|
|
271
|
+
}
|
|
272
|
+
|
|
100
273
|
// ── Span Operations ─────────────────────────────────────────────────────
|
|
101
274
|
|
|
102
275
|
async insertSpan(span: {
|
|
@@ -15,6 +15,8 @@ export class HeartbeatFake {
|
|
|
15
15
|
content,
|
|
16
16
|
tags,
|
|
17
17
|
requestId: null,
|
|
18
|
+
originType: 'standalone',
|
|
19
|
+
originId: null,
|
|
18
20
|
createdAt: Date.now(),
|
|
19
21
|
})
|
|
20
22
|
}
|
|
@@ -29,6 +31,10 @@ export class HeartbeatFake {
|
|
|
29
31
|
return this.entries.filter((e) => e.type === type)
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
forOrigin(originType: string, originId: string): Array<{ type: string; content: Record<string, any>; tags?: string[] | undefined }> {
|
|
35
|
+
return this.entries.filter((e) => e.originType === originType && e.originId === originId)
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
hasRecorded(type: EntryType, match?: string | RegExp): boolean {
|
|
33
39
|
const filtered = this.forType(type)
|
|
34
40
|
if (!match) return filtered.length > 0
|
package/src/tracing/Tracer.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
2
|
import { Span } from './Span.ts'
|
|
3
3
|
import { generateTraceId, generateSpanId, parseTraceparent } from './TraceContext.ts'
|
|
4
|
+
import type { OriginType } from '../contracts/Entry.ts'
|
|
4
5
|
import type { HeartbeatStore } from '../storage/HeartbeatStore.ts'
|
|
5
6
|
|
|
6
7
|
interface TraceState {
|
|
7
8
|
traceId: string
|
|
8
9
|
requestId: string
|
|
10
|
+
originType: OriginType
|
|
9
11
|
spanStack: Span[]
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -39,6 +41,7 @@ export class Tracer {
|
|
|
39
41
|
this.ctx.enterWith({
|
|
40
42
|
traceId,
|
|
41
43
|
requestId,
|
|
44
|
+
originType: 'request',
|
|
42
45
|
spanStack: [],
|
|
43
46
|
})
|
|
44
47
|
}
|
|
@@ -51,6 +54,35 @@ export class Tracer {
|
|
|
51
54
|
this.ctx = new AsyncLocalStorage<TraceState>()
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Start a new command trace context.
|
|
59
|
+
* Should be called at the beginning of each CLI command execution.
|
|
60
|
+
*/
|
|
61
|
+
startCommand(commandId: string): void {
|
|
62
|
+
const traceId = generateTraceId()
|
|
63
|
+
|
|
64
|
+
this.ctx.enterWith({
|
|
65
|
+
traceId,
|
|
66
|
+
requestId: commandId,
|
|
67
|
+
originType: 'command',
|
|
68
|
+
spanStack: [],
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* End the current command trace context.
|
|
74
|
+
*/
|
|
75
|
+
endCommand(): void {
|
|
76
|
+
this.endRequest()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the current origin type, if inside a trace context.
|
|
81
|
+
*/
|
|
82
|
+
currentOriginType(): OriginType | null {
|
|
83
|
+
return this.ctx.getStore()?.originType ?? null
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
/**
|
|
55
87
|
* Execute a callback within a new span.
|
|
56
88
|
* Automatically sets parent-child relationships from the span stack.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Watcher } from '../contracts/Watcher.ts'
|
|
2
|
+
import type { CommandEntryContent } from '../contracts/Entry.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Records CLI command executions for the Heartbeat dashboard.
|
|
6
|
+
*
|
|
7
|
+
* Captures: command name, arguments, options, exit code, duration, and output.
|
|
8
|
+
*
|
|
9
|
+
* Integration: HeartbeatServiceProvider hooks into the command kernel
|
|
10
|
+
* to feed executed commands to this watcher.
|
|
11
|
+
*/
|
|
12
|
+
export class CommandWatcher extends Watcher {
|
|
13
|
+
override register(): void {
|
|
14
|
+
// CommandWatcher is driven by wrapping the command kernel.
|
|
15
|
+
// HeartbeatServiceProvider hooks into Command.run().
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
recordCommand(data: CommandEntryContent): void {
|
|
19
|
+
if (!this.isEnabled()) return
|
|
20
|
+
|
|
21
|
+
this.record('command', data, [data.exit_code === 0 ? 'success' : 'error'])
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -10,6 +10,17 @@ export class EventWatcher extends Watcher {
|
|
|
10
10
|
private static readonly INTERNAL_PREFIXES = [
|
|
11
11
|
'RecordHeartbeatEntries',
|
|
12
12
|
'HeartbeatMetrics',
|
|
13
|
+
'QueryExecuted',
|
|
14
|
+
'TransactionBeginning',
|
|
15
|
+
'TransactionCommitted',
|
|
16
|
+
'TransactionRolledBack',
|
|
17
|
+
'MigrationStarted',
|
|
18
|
+
'MigrationEnded',
|
|
19
|
+
'RouteMatched',
|
|
20
|
+
'CacheHit',
|
|
21
|
+
'CacheMissed',
|
|
22
|
+
'KeyWritten',
|
|
23
|
+
'KeyForgotten',
|
|
13
24
|
]
|
|
14
25
|
|
|
15
26
|
override register(_on: (eventClass: any, handler: (event: any) => void) => void, onAny: (handler: (event: any) => void) => void): void {
|
|
@@ -31,6 +42,8 @@ export class EventWatcher extends Watcher {
|
|
|
31
42
|
const content: EventEntryContent = {
|
|
32
43
|
event_class: eventClass,
|
|
33
44
|
listeners_count: 0, // populated by the dispatcher if available
|
|
45
|
+
payload: null,
|
|
46
|
+
listeners: [],
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
this.record('event', content, [eventClass])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Floating debug widget — injected into HTML responses when APP_DEBUG=true.
|
|
3
|
-
*
|
|
3
|
+
* Monospace neon emerald pill with Apple-like raised panel.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export function renderWidget(data: {
|
|
@@ -13,38 +13,65 @@ export function renderWidget(data: {
|
|
|
13
13
|
const { duration, memory, status, queries, dashboardPath } = data
|
|
14
14
|
const memMB = (memory / 1024 / 1024).toFixed(1)
|
|
15
15
|
const durationMs = duration.toFixed(0)
|
|
16
|
-
const statusColor = status >= 500 ? '#
|
|
16
|
+
const statusColor = status >= 500 ? '#fb7185' : status >= 400 ? '#fbbf24' : '#34d399'
|
|
17
17
|
|
|
18
18
|
return `<!-- mantiq:heartbeat-widget -->
|
|
19
19
|
<style>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
|
|
20
|
+
@keyframes mw-fade{from{opacity:0;transform:translateY(8px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}
|
|
21
|
+
@keyframes mw-glow{0%,100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}50%{box-shadow:0 0 12px 2px rgba(52,211,153,.15)}}
|
|
22
|
+
#__mw{position:fixed;bottom:20px;right:20px;z-index:99999;font-family:'SF Mono',ui-monospace,'Cascadia Mono','JetBrains Mono',Menlo,monospace;font-size:11px}
|
|
23
|
+
#__mw_pill{
|
|
24
|
+
display:flex;align-items:center;gap:8px;
|
|
25
|
+
background:#0b0f0d;border:1px solid #1a2e25;border-radius:100px;
|
|
26
|
+
padding:8px 16px 8px 12px;cursor:pointer;color:#6ee7b7;
|
|
27
|
+
transition:all .25s cubic-bezier(.4,0,.2,1);user-select:none;
|
|
28
|
+
animation:mw-fade .3s ease-out,mw-glow 3s ease-in-out infinite;
|
|
29
|
+
backdrop-filter:blur(12px);
|
|
30
|
+
}
|
|
31
|
+
#__mw_pill:hover{border-color:#34d399;background:#0e1412}
|
|
32
|
+
#__mw_logo{display:flex;align-items:center;border-right:1px solid #1a2e25;padding-right:10px;margin-right:2px}
|
|
33
|
+
#__mw_logo span{width:6px;height:6px;border-radius:50%;background:#34d399;box-shadow:0 0 8px #34d399}
|
|
34
|
+
#__mw_stats{display:flex;align-items:center;gap:8px;font-weight:600}
|
|
35
|
+
#__mw_stats b{color:#f0fdf4;font-weight:700}
|
|
27
36
|
#__mw_dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
|
28
|
-
#__mw_sep{color:#
|
|
29
|
-
#__mw_panel{
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
#__mw_sep{color:#1a2e25;font-weight:400}
|
|
38
|
+
#__mw_panel{
|
|
39
|
+
display:none;position:absolute;bottom:calc(100% + 12px);right:0;
|
|
40
|
+
background:#0b0f0d;border:1px solid #1a2e25;border-radius:16px;
|
|
41
|
+
min-width:300px;overflow:hidden;
|
|
42
|
+
animation:mw-fade .2s ease-out;
|
|
43
|
+
backdrop-filter:blur(16px);
|
|
44
|
+
}
|
|
45
|
+
#__mw_panel header{
|
|
46
|
+
padding:16px 18px;border-bottom:1px solid #1a2e25;
|
|
47
|
+
display:flex;align-items:center;justify-content:space-between;
|
|
48
|
+
}
|
|
49
|
+
#__mw_panel header .brand{
|
|
50
|
+
display:flex;align-items:center;gap:8px;color:#f0fdf4;font-weight:700;
|
|
51
|
+
font-size:12px;letter-spacing:-.01em;
|
|
52
|
+
}
|
|
53
|
+
#__mw_panel header .brand i{width:6px;height:6px;border-radius:50%;background:#34d399;box-shadow:0 0 6px #34d399}
|
|
33
54
|
#__mw_grid{display:grid;grid-template-columns:1fr 1fr;gap:0}
|
|
34
|
-
#__mw_grid .cell{padding:
|
|
35
|
-
#__mw_grid .cell:
|
|
36
|
-
#__mw_grid .cell
|
|
37
|
-
#__mw_grid .cell
|
|
38
|
-
#__mw_grid .cell .val
|
|
39
|
-
#
|
|
40
|
-
#__mw_cta
|
|
41
|
-
#__mw_cta a
|
|
55
|
+
#__mw_grid .cell{padding:16px 18px;border-bottom:1px solid #1a2e25;transition:background .15s}
|
|
56
|
+
#__mw_grid .cell:hover{background:rgba(52,211,153,.05)}
|
|
57
|
+
#__mw_grid .cell:nth-child(odd){border-right:1px solid #1a2e25}
|
|
58
|
+
#__mw_grid .cell label{display:block;color:#3b5249;font-size:9px;text-transform:uppercase;letter-spacing:.1em;margin-bottom:6px;font-weight:700}
|
|
59
|
+
#__mw_grid .cell .val{color:#f0fdf4;font-weight:800;font-size:22px;letter-spacing:-.03em;font-variant-numeric:tabular-nums}
|
|
60
|
+
#__mw_grid .cell .val small{color:#3b5249;font-size:10px;font-weight:500;margin-left:2px}
|
|
61
|
+
#__mw_cta{padding:14px 18px}
|
|
62
|
+
#__mw_cta a{
|
|
63
|
+
display:flex;align-items:center;justify-content:center;gap:6px;
|
|
64
|
+
color:#0b0f0d;background:#34d399;padding:10px;border-radius:12px;
|
|
65
|
+
font-size:11px;font-weight:700;text-decoration:none;letter-spacing:.01em;
|
|
66
|
+
transition:all .2s;border:1px solid transparent;
|
|
67
|
+
}
|
|
68
|
+
#__mw_cta a:hover{background:#6ee7b7;box-shadow:0 0 16px rgba(52,211,153,.3)}
|
|
42
69
|
</style>
|
|
43
70
|
<div id="__mw">
|
|
44
71
|
<div id="__mw_pill" onclick="document.getElementById('__mw_panel').style.display=document.getElementById('__mw_panel').style.display==='none'?'block':'none'">
|
|
45
72
|
<div id="__mw_logo"><span></span></div>
|
|
46
73
|
<div id="__mw_stats">
|
|
47
|
-
<span id="__mw_dot" style="background:${statusColor}"></span>
|
|
74
|
+
<span id="__mw_dot" style="background:${statusColor};box-shadow:0 0 6px ${statusColor}"></span>
|
|
48
75
|
<b>${durationMs}ms</b>
|
|
49
76
|
<span id="__mw_sep">·</span>
|
|
50
77
|
<span>${memMB}MB</span>
|
|
@@ -54,7 +81,7 @@ export function renderWidget(data: {
|
|
|
54
81
|
</div>
|
|
55
82
|
<div id="__mw_panel">
|
|
56
83
|
<header>
|
|
57
|
-
<div class="brand"><i></i>
|
|
84
|
+
<div class="brand"><i></i>heartbeat</div>
|
|
58
85
|
</header>
|
|
59
86
|
<div id="__mw_grid">
|
|
60
87
|
<div class="cell"><label>Duration</label><div class="val">${durationMs}<small>ms</small></div></div>
|
|
@@ -71,7 +98,6 @@ export function renderWidget(data: {
|
|
|
71
98
|
(function(){
|
|
72
99
|
document.addEventListener('keydown',function(e){if(e.key==='Escape')document.getElementById('__mw_panel').style.display='none'});
|
|
73
100
|
|
|
74
|
-
// Intercept fetch to read X-Heartbeat header and update widget
|
|
75
101
|
var _fetch=window.fetch;
|
|
76
102
|
window.fetch=function(){
|
|
77
103
|
return _fetch.apply(this,arguments).then(function(res){
|
|
@@ -82,17 +108,14 @@ export function renderWidget(data: {
|
|
|
82
108
|
};
|
|
83
109
|
|
|
84
110
|
function updateWidget(header){
|
|
85
|
-
// Format: 15ms;1.6MB;200;0q
|
|
86
111
|
var p=header.split(';');
|
|
87
112
|
if(p.length<4)return;
|
|
88
113
|
var dur=p[0],mem=p[1],status=parseInt(p[2]),queries=p[3];
|
|
89
|
-
var sc=status>=500?'#
|
|
114
|
+
var sc=status>=500?'#fb7185':status>=400?'#fbbf24':'#34d399';
|
|
90
115
|
|
|
91
|
-
// Update pill
|
|
92
116
|
var pill=document.getElementById('__mw_stats');
|
|
93
|
-
if(pill)pill.innerHTML='<span id="__mw_dot" style="width:5px;height:5px;border-radius:50%;background:'+sc+';flex-shrink:0"></span><b style="color:#
|
|
117
|
+
if(pill)pill.innerHTML='<span id="__mw_dot" style="width:5px;height:5px;border-radius:50%;background:'+sc+';box-shadow:0 0 6px '+sc+';flex-shrink:0"></span><b style="color:#f0fdf4;font-weight:700">'+dur+'</b><span id="__mw_sep" style="color:#1a2e25">·</span><span>'+mem+'</span><span id="__mw_sep" style="color:#1a2e25">·</span><span>'+queries+'</span>';
|
|
94
118
|
|
|
95
|
-
// Update panel grid
|
|
96
119
|
var cells=document.querySelectorAll('#__mw_grid .cell .val');
|
|
97
120
|
if(cells.length>=4){
|
|
98
121
|
cells[0].innerHTML=dur.replace('ms','')+'<small>ms</small>';
|