@mantiq/heartbeat 0.1.5 → 0.2.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/heartbeat",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Observability, APM & queue monitoring for MantiqJS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@ import { JobWatcher } from './watchers/JobWatcher.ts'
18
18
  import { EventWatcher } from './watchers/EventWatcher.ts'
19
19
  import { ModelWatcher } from './watchers/ModelWatcher.ts'
20
20
  import { LogWatcher } from './watchers/LogWatcher.ts'
21
+ import { MailWatcher } from './watchers/MailWatcher.ts'
21
22
  import { ScheduleWatcher } from './watchers/ScheduleWatcher.ts'
22
23
  import { CreateHeartbeatTables } from './migrations/CreateHeartbeatTables.ts'
23
24
  import { DashboardController } from './dashboard/DashboardController.ts'
@@ -187,6 +188,7 @@ export class HeartbeatServiceProvider extends ServiceProvider {
187
188
  ['event', new EventWatcher()],
188
189
  ['model', new ModelWatcher()],
189
190
  ['log', new LogWatcher()],
191
+ ['mail', new MailWatcher()],
190
192
  ['schedule', new ScheduleWatcher()],
191
193
  ]
192
194
 
@@ -194,8 +196,8 @@ export class HeartbeatServiceProvider extends ServiceProvider {
194
196
  const onAny = eventBus.onAny.bind(eventBus)
195
197
 
196
198
  for (const [key, watcher] of watcherMap) {
197
- const watcherConfig = config.watchers[key]
198
- if (!watcherConfig.enabled) continue
199
+ const watcherConfig = config.watchers[key as keyof typeof config.watchers]
200
+ if (!watcherConfig?.enabled) continue
199
201
 
200
202
  watcher.configure(watcherConfig as Record<string, any>)
201
203
  heartbeat.addWatcher(watcher)
@@ -11,6 +11,7 @@ export type EntryType =
11
11
  | 'model'
12
12
  | 'log'
13
13
  | 'schedule'
14
+ | 'mail'
14
15
 
15
16
  /**
16
17
  * A raw pending entry before it's persisted — pushed by watchers into the buffer.
@@ -125,6 +126,20 @@ export interface ScheduleEntryContent {
125
126
  status: 'success' | 'error'
126
127
  }
127
128
 
129
+ export interface MailEntryContent {
130
+ to: string[]
131
+ cc: string[]
132
+ bcc: string[]
133
+ subject: string
134
+ from: string
135
+ mailer: string
136
+ html: string | null
137
+ text: string | null
138
+ attachments: string[]
139
+ duration: number
140
+ queued: boolean
141
+ }
142
+
128
143
  // ── Span types ──────────────────────────────────────────────────────────────
129
144
 
130
145
  export type SpanStatus = 'ok' | 'error'
@@ -21,6 +21,7 @@ export interface HeartbeatConfig {
21
21
  model: { enabled: boolean }
22
22
  log: { enabled: boolean; level: string }
23
23
  schedule: { enabled: boolean }
24
+ mail: { enabled: boolean }
24
25
  }
25
26
  tracing: { enabled: boolean; propagate: boolean }
26
27
  sampling: { rate: number; always_sample_errors: boolean }
@@ -50,6 +51,7 @@ export const DEFAULT_CONFIG: HeartbeatConfig = {
50
51
  model: { enabled: true },
51
52
  log: { enabled: true, level: 'debug' },
52
53
  schedule: { enabled: true },
54
+ mail: { enabled: true },
53
55
  },
54
56
  tracing: { enabled: true, propagate: true },
55
57
  sampling: { rate: 1.0, always_sample_errors: true },
@@ -9,6 +9,8 @@ import { renderCachePage } from './pages/CachePage.ts'
9
9
  import { renderEventsPage } from './pages/EventsPage.ts'
10
10
  import { renderPerformancePage } from './pages/PerformancePage.ts'
11
11
  import { renderRequestDetailPage } from './pages/RequestDetailPage.ts'
12
+ import { renderMailPage } from './pages/MailPage.ts'
13
+ import { renderMailDetailPage } from './pages/MailDetailPage.ts'
12
14
  import type { EntryType } from '../contracts/Entry.ts'
13
15
 
14
16
  /**
@@ -69,6 +71,8 @@ export class DashboardController {
69
71
  return renderEventsPage(this.store, this.basePath)
70
72
  case 'performance':
71
73
  return renderPerformancePage(this.metrics, this.basePath)
74
+ case 'mail':
75
+ return renderMailPage(this.store, this.basePath)
72
76
  }
73
77
 
74
78
  // Parameterized routes
@@ -78,6 +82,12 @@ export class DashboardController {
78
82
  return html ?? this.render404()
79
83
  }
80
84
 
85
+ const mailDetail = sub.match(/^mail\/([a-f0-9-]+)$/)
86
+ if (mailDetail) {
87
+ const html = await renderMailDetailPage(this.store, mailDetail[1]!, this.basePath)
88
+ return html ?? this.render404()
89
+ }
90
+
81
91
  return this.render404()
82
92
  }
83
93
 
@@ -0,0 +1,110 @@
1
+ import { renderLayout } from '../shared/layout.ts'
2
+ import { badge, durationBadge, escapeHtml } from '../shared/components.ts'
3
+ import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
+ import type { MailEntryContent } from '../../contracts/Entry.ts'
5
+
6
+ export async function renderMailDetailPage(store: HeartbeatStore, uuid: string, basePath: string): Promise<string | null> {
7
+ const entry = await store.getEntry(uuid)
8
+ if (!entry || entry.type !== 'mail') return null
9
+
10
+ const c = JSON.parse(entry.content) as MailEntryContent
11
+ const recorded = new Date(entry.created_at)
12
+ 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')}`
13
+
14
+ const attachmentsList = c.attachments.length > 0
15
+ ? c.attachments.map(a => `<span class="mono sm" style="padding:4px 8px;background:var(--bg-2);border-radius:4px;font-size:11px">${escapeHtml(a)}</span>`).join(' ')
16
+ : '<span class="dim sm">None</span>'
17
+
18
+ const content = `
19
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
20
+ <a href="${basePath}/mail" style="color:var(--fg-3);text-decoration:none;font-size:13px">&larr; Mail</a>
21
+ </div>
22
+
23
+ <div class="card mb" style="padding:16px 18px">
24
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
25
+ <span style="color:var(--fg-0);font-size:15px;font-weight:600">${escapeHtml(c.subject || '(no subject)')}</span>
26
+ ${c.queued ? badge('queued', 'amber') : badge('sent', 'green')}
27
+ ${badge(c.mailer, 'mute')}
28
+ ${durationBadge(c.duration)}
29
+ </div>
30
+ <div class="sm dim" style="margin-top:6px">${timeStr}</div>
31
+ </div>
32
+
33
+ <div class="card mb">
34
+ <div class="card-title">Details</div>
35
+ <div class="meta-grid">
36
+ <div class="meta-item">
37
+ <div class="meta-label">From</div>
38
+ <div class="meta-value mono sm">${escapeHtml(c.from)}</div>
39
+ </div>
40
+ <div class="meta-item">
41
+ <div class="meta-label">To</div>
42
+ <div class="meta-value mono sm">${escapeHtml(c.to.join(', '))}</div>
43
+ </div>
44
+ ${c.cc.length > 0 ? `<div class="meta-item">
45
+ <div class="meta-label">CC</div>
46
+ <div class="meta-value mono sm">${escapeHtml(c.cc.join(', '))}</div>
47
+ </div>` : ''}
48
+ ${c.bcc.length > 0 ? `<div class="meta-item">
49
+ <div class="meta-label">BCC</div>
50
+ <div class="meta-value mono sm">${escapeHtml(c.bcc.join(', '))}</div>
51
+ </div>` : ''}
52
+ <div class="meta-item">
53
+ <div class="meta-label">Attachments</div>
54
+ <div class="meta-value">${attachmentsList}</div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="card">
60
+ <div class="tabs">
61
+ <input type="radio" name="mail-tab" id="tab-html" checked>
62
+ <label for="tab-html">HTML Preview</label>
63
+ <input type="radio" name="mail-tab" id="tab-source">
64
+ <label for="tab-source">Source</label>
65
+ <input type="radio" name="mail-tab" id="tab-text">
66
+ <label for="tab-text">Plain Text</label>
67
+ </div>
68
+
69
+ <div class="tab-panel" id="panel-html" style="padding:0">
70
+ ${c.html
71
+ ? `<iframe id="mail-preview" srcdoc="${escapeAttr(c.html)}" style="width:100%;border:none;min-height:500px;background:#fff;border-radius:0 0 8px 8px" onload="this.style.height=this.contentDocument.body.scrollHeight+'px'"></iframe>`
72
+ : `<div style="padding:40px;text-align:center;color:var(--fg-3)">No HTML content</div>`
73
+ }
74
+ </div>
75
+
76
+ <div class="tab-panel" id="panel-source" style="padding:14px 0 0">
77
+ <pre class="code-block" style="max-height:500px;overflow:auto">${escapeHtml(c.html ?? '(no HTML)')}</pre>
78
+ </div>
79
+
80
+ <div class="tab-panel" id="panel-text" style="padding:14px 18px">
81
+ <pre style="white-space:pre-wrap;font-family:var(--font-mono, monospace);font-size:13px;color:var(--fg-1);line-height:1.6">${escapeHtml(c.text ?? '(no plain text)')}</pre>
82
+ </div>
83
+ </div>
84
+
85
+ <script>
86
+ (function() {
87
+ var tabs = document.querySelectorAll('[name="mail-tab"]');
88
+ var panels = {
89
+ 'tab-html': document.getElementById('panel-html'),
90
+ 'tab-source': document.getElementById('panel-source'),
91
+ 'tab-text': document.getElementById('panel-text'),
92
+ };
93
+ function show() {
94
+ for (var id in panels) { panels[id].style.display = 'none'; }
95
+ for (var t of tabs) {
96
+ if (t.checked) { panels[t.id].style.display = 'block'; }
97
+ }
98
+ }
99
+ for (var t of tabs) t.addEventListener('change', show);
100
+ show();
101
+ })();
102
+ </script>
103
+ `
104
+
105
+ return renderLayout({ title: 'Mail Detail', activePage: 'mail', basePath, content })
106
+ }
107
+
108
+ function escapeAttr(s: string): string {
109
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
110
+ }
@@ -0,0 +1,50 @@
1
+ import { renderLayout } from '../shared/layout.ts'
2
+ import { durationBadge, timeAgo, escapeHtml, badge } from '../shared/components.ts'
3
+ import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
+ import type { MailEntryContent } from '../../contracts/Entry.ts'
5
+
6
+ export async function renderMailPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
+ const entries = await store.getEntries('mail', 50)
8
+
9
+ const rows = entries.map((e) => {
10
+ const c = JSON.parse(e.content) as MailEntryContent
11
+ const to = c.to.length > 2
12
+ ? `${escapeHtml(c.to.slice(0, 2).join(', '))} +${c.to.length - 2}`
13
+ : escapeHtml(c.to.join(', '))
14
+
15
+ return `<tr>
16
+ <td style="padding:10px 14px">
17
+ <a href="${basePath}/mail/${e.uuid}" style="color:var(--fg-0);text-decoration:none;font-weight:500">${escapeHtml(c.subject || '(no subject)')}</a>
18
+ </td>
19
+ <td class="mono sm" style="padding:10px 14px;color:var(--fg-2)">${to}</td>
20
+ <td style="padding:10px 14px">${badge(c.mailer, 'mute')}</td>
21
+ <td style="padding:10px 14px">${c.queued ? badge('queued', 'amber') : badge('sent', 'green')}</td>
22
+ <td style="padding:10px 14px">${durationBadge(c.duration)}</td>
23
+ <td class="sm dim" style="padding:10px 14px;text-align:right">${timeAgo(e.created_at)}</td>
24
+ </tr>`
25
+ }).join('')
26
+
27
+ const empty = entries.length === 0
28
+ ? `<tr><td colspan="6" style="padding:40px;text-align:center;color:var(--fg-3)">No mail entries recorded yet</td></tr>`
29
+ : ''
30
+
31
+ const content = `
32
+ <div class="card">
33
+ <table style="width:100%;border-collapse:collapse">
34
+ <thead>
35
+ <tr style="border-bottom:1px solid var(--border)">
36
+ <th class="th">Subject</th>
37
+ <th class="th">To</th>
38
+ <th class="th">Mailer</th>
39
+ <th class="th">Status</th>
40
+ <th class="th">Duration</th>
41
+ <th class="th" style="text-align:right">Time</th>
42
+ </tr>
43
+ </thead>
44
+ <tbody>${rows}${empty}</tbody>
45
+ </table>
46
+ </div>
47
+ `
48
+
49
+ return renderLayout({ title: 'Mail', activePage: 'mail', basePath, content })
50
+ }
@@ -19,6 +19,7 @@ export function renderLayout(options: {
19
19
  { key: 'jobs', label: 'Jobs', icon: ICONS.layers },
20
20
  { key: 'cache', label: 'Cache', icon: ICONS.box },
21
21
  { key: 'events', label: 'Events', icon: ICONS.zap },
22
+ { key: 'mail', label: 'Mail', icon: ICONS.mail },
22
23
  { key: 'performance', label: 'Performance', icon: ICONS.activity },
23
24
  ]
24
25
 
@@ -85,6 +86,7 @@ const ICONS = {
85
86
  box: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
86
87
  zap: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
87
88
  activity: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
89
+ mail: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
88
90
  moon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
89
91
  }
90
92
 
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export { EventWatcher } from './watchers/EventWatcher.ts'
43
43
  export { ModelWatcher } from './watchers/ModelWatcher.ts'
44
44
  export { LogWatcher } from './watchers/LogWatcher.ts'
45
45
  export { ScheduleWatcher } from './watchers/ScheduleWatcher.ts'
46
+ export { MailWatcher } from './watchers/MailWatcher.ts'
46
47
 
47
48
  // ── Tracing ─────────────────────────────────────────────────────────────────
48
49
  export { Tracer } from './tracing/Tracer.ts'
@@ -0,0 +1,52 @@
1
+ import { Watcher } from '../contracts/Watcher.ts'
2
+ import type { MailEntryContent } from '../contracts/Entry.ts'
3
+
4
+ /**
5
+ * Records sent emails for the Heartbeat dashboard.
6
+ *
7
+ * Captures: recipients, subject, from, mailer driver, HTML body (for preview),
8
+ * attachments list, duration, and whether it was queued.
9
+ *
10
+ * Integration: HeartbeatServiceProvider wraps MailManager.send() to intercept
11
+ * outgoing messages and feed them to this watcher.
12
+ */
13
+ export class MailWatcher extends Watcher {
14
+ override register(): void {
15
+ // Driven by HeartbeatServiceProvider wrapping mail transport
16
+ }
17
+
18
+ recordMail(data: {
19
+ to: string[]
20
+ cc?: string[]
21
+ bcc?: string[]
22
+ subject: string
23
+ from: string
24
+ mailer: string
25
+ html: string | null
26
+ text: string | null
27
+ attachments: string[]
28
+ duration: number
29
+ queued: boolean
30
+ }): void {
31
+ if (!this.isEnabled()) return
32
+
33
+ const content: MailEntryContent = {
34
+ to: data.to,
35
+ cc: data.cc ?? [],
36
+ bcc: data.bcc ?? [],
37
+ subject: data.subject,
38
+ from: data.from,
39
+ mailer: data.mailer,
40
+ html: data.html,
41
+ text: data.text,
42
+ attachments: data.attachments,
43
+ duration: data.duration,
44
+ queued: data.queued,
45
+ }
46
+
47
+ const tags = ['mail', data.mailer]
48
+ if (data.queued) tags.push('queued')
49
+
50
+ this.record('mail', content, tags)
51
+ }
52
+ }