@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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/Heartbeat.ts +15 -27
  3. package/src/HeartbeatServiceProvider.ts +66 -0
  4. package/src/contracts/Entry.ts +19 -0
  5. package/src/contracts/HeartbeatConfig.ts +2 -0
  6. package/src/dashboard/DashboardController.ts +71 -12
  7. package/src/dashboard/pages/CachePage.ts +61 -5
  8. package/src/dashboard/pages/CommandDetailPage.ts +216 -0
  9. package/src/dashboard/pages/CommandsPage.ts +72 -0
  10. package/src/dashboard/pages/EventsPage.ts +59 -6
  11. package/src/dashboard/pages/ExceptionsPage.ts +37 -6
  12. package/src/dashboard/pages/JobsPage.ts +61 -5
  13. package/src/dashboard/pages/LogsPage.ts +116 -0
  14. package/src/dashboard/pages/ModelsPage.ts +112 -0
  15. package/src/dashboard/pages/NotificationsPage.ts +87 -0
  16. package/src/dashboard/pages/OverviewPage.ts +109 -45
  17. package/src/dashboard/pages/PerformancePage.ts +151 -20
  18. package/src/dashboard/pages/QueriesPage.ts +92 -8
  19. package/src/dashboard/pages/RequestDetailPage.ts +227 -3
  20. package/src/dashboard/pages/RequestsPage.ts +90 -3
  21. package/src/dashboard/pages/SchedulesPage.ts +71 -0
  22. package/src/dashboard/shared/components.ts +140 -0
  23. package/src/dashboard/shared/layout.ts +327 -108
  24. package/src/index.ts +9 -0
  25. package/src/middleware/HeartbeatMiddleware.ts +26 -9
  26. package/src/migrations/CreateHeartbeatTables.ts +14 -0
  27. package/src/models/EntryModel.ts +1 -1
  28. package/src/storage/HeartbeatStore.ts +174 -1
  29. package/src/testing/HeartbeatFake.ts +6 -0
  30. package/src/tracing/Tracer.ts +32 -0
  31. package/src/watchers/CommandWatcher.ts +23 -0
  32. package/src/watchers/EventWatcher.ts +13 -0
  33. package/src/watchers/ScheduleWatcher.ts +1 -0
  34. 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: null,
117
- routeName: null,
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 statsHeader = `${Math.round(totalDuration)}ms;${mem}MB;${response!.status};0q`
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: 0,
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> {
@@ -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
@@ -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])
@@ -25,6 +25,7 @@ export class ScheduleWatcher extends Watcher {
25
25
  expression: data.expression,
26
26
  duration: data.duration,
27
27
  status: data.status,
28
+ output: null,
28
29
  }
29
30
 
30
31
  const tags: string[] = [data.status]
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Floating debug widget — injected into HTML responses when APP_DEBUG=true.
3
- * Compact pill with mantiq branding, expands to stats panel on click.
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 ? '#f87171' : status >= 400 ? '#fbbf24' : '#34d399'
16
+ const statusColor = status >= 500 ? '#fb7185' : status >= 400 ? '#fbbf24' : '#34d399'
17
17
 
18
18
  return `<!-- mantiq:heartbeat-widget -->
19
19
  <style>
20
- #__mw{position:fixed;bottom:16px;right:16px;z-index:99999;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;font-size:12px}
21
- #__mw_pill{display:flex;align-items:center;gap:6px;background:#0a0a0b;border:1px solid #27272a;border-radius:100px;padding:7px 14px 7px 10px;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.5);color:#71717a;transition:border-color .2s;user-select:none}
22
- #__mw_pill:hover{border-color:#34d399}
23
- #__mw_logo{display:flex;align-items:center;border-right:1px solid #27272a;padding-right:8px;margin-right:2px}
24
- #__mw_logo span{width:7px;height:7px;border-radius:50%;background:#34d399}
25
- #__mw_stats{display:flex;align-items:center;gap:6px}
26
- #__mw_stats b{color:#fafafa;font-weight:600}
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:#27272a}
29
- #__mw_panel{display:none;position:absolute;bottom:calc(100% + 10px);right:0;background:#0a0a0b;border:1px solid #27272a;border-radius:12px;min-width:280px;box-shadow:0 12px 32px rgba(0,0,0,.6);overflow:hidden}
30
- #__mw_panel header{padding:14px 16px;border-bottom:1px solid #1e1e1e;display:flex;align-items:center;justify-content:space-between}
31
- #__mw_panel header .brand{display:flex;align-items:center;gap:6px;color:#fafafa;font-weight:700;font-size:12px;letter-spacing:-.01em}
32
- #__mw_panel header .brand i{width:6px;height:6px;border-radius:50%;background:#34d399}
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:14px 16px;border-bottom:1px solid #1e1e1e}
35
- #__mw_grid .cell:nth-child(odd){border-right:1px solid #1e1e1e}
36
- #__mw_grid .cell label{display:block;color:#52525b;font-size:10px;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
37
- #__mw_grid .cell .val{color:#fafafa;font-weight:700;font-size:18px;letter-spacing:-.02em}
38
- #__mw_grid .cell .val small{color:#52525b;font-size:11px;font-weight:400;margin-left:1px}
39
- #__mw_cta{padding:12px 16px}
40
- #__mw_cta a{display:flex;align-items:center;justify-content:center;gap:6px;color:#0a0a0b;background:#34d399;padding:8px;border-radius:8px;font-size:12px;font-weight:600;text-decoration:none;transition:background .15s}
41
- #__mw_cta a:hover{background:#10b981}
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">&middot;</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>mantiq</div>
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?'#f87171':status>=400?'#fbbf24':'#34d399';
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:#fafafa;font-weight:600">'+dur+'</b><span id="__mw_sep" style="color:#27272a">&middot;</span><span>'+mem+'</span><span id="__mw_sep" style="color:#27272a">&middot;</span><span>'+queries+'</span>';
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">&middot;</span><span>'+mem+'</span><span id="__mw_sep" style="color:#1a2e25">&middot;</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>';