@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 +1 -1
- package/src/HeartbeatServiceProvider.ts +4 -2
- package/src/contracts/Entry.ts +15 -0
- package/src/contracts/HeartbeatConfig.ts +2 -0
- package/src/dashboard/DashboardController.ts +10 -0
- package/src/dashboard/pages/MailDetailPage.ts +110 -0
- package/src/dashboard/pages/MailPage.ts +50 -0
- package/src/dashboard/shared/layout.ts +2 -0
- package/src/index.ts +1 -0
- package/src/watchers/MailWatcher.ts +52 -0
package/package.json
CHANGED
|
@@ -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
|
|
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)
|
package/src/contracts/Entry.ts
CHANGED
|
@@ -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">← 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|