@mantiq/heartbeat 0.1.4 → 0.2.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/HeartbeatServiceProvider.ts +2 -0
- 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/CachePage.ts +0 -1
- package/src/dashboard/pages/EventsPage.ts +0 -1
- package/src/dashboard/pages/ExceptionsPage.ts +0 -1
- package/src/dashboard/pages/JobsPage.ts +0 -1
- package/src/dashboard/pages/MailDetailPage.ts +110 -0
- package/src/dashboard/pages/MailPage.ts +50 -0
- package/src/dashboard/pages/OverviewPage.ts +0 -1
- package/src/dashboard/pages/PerformancePage.ts +0 -1
- package/src/dashboard/pages/QueriesPage.ts +0 -1
- package/src/dashboard/pages/RequestDetailPage.ts +14 -14
- package/src/dashboard/pages/RequestsPage.ts +0 -1
- package/src/dashboard/shared/layout.ts +5 -2
- 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
|
|
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
|
|
|
@@ -29,7 +29,6 @@ export async function renderCachePage(store: HeartbeatStore, basePath: string):
|
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
const content = `
|
|
32
|
-
<h1 class="page-title">Cache</h1>
|
|
33
32
|
<div class="stats">
|
|
34
33
|
${stat('Hit Rate', hitRate, `${hits} hits / ${misses} misses`)}
|
|
35
34
|
${stat('Hits', hits.toString())}
|
|
@@ -30,7 +30,6 @@ export async function renderExceptionsPage(store: HeartbeatStore, basePath: stri
|
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
const content = `
|
|
33
|
-
<h1 class="page-title">Exceptions</h1>
|
|
34
33
|
<div class="stats">
|
|
35
34
|
${stat('Total', entries.length.toString())}
|
|
36
35
|
${stat('Groups', groups.length.toString(), 'Unique')}
|
|
@@ -29,7 +29,6 @@ export async function renderJobsPage(store: HeartbeatStore, basePath: string): P
|
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
const content = `
|
|
32
|
-
<h1 class="page-title">Jobs</h1>
|
|
33
32
|
<div class="stats">
|
|
34
33
|
${stat('Processed', processed.toString(), 'Completed')}
|
|
35
34
|
${stat('Failed', failed.toString())}
|
|
@@ -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
|
+
}
|
|
@@ -50,7 +50,6 @@ export async function renderOverviewPage(store: HeartbeatStore, metrics: Metrics
|
|
|
50
50
|
], labels)
|
|
51
51
|
|
|
52
52
|
const content = `
|
|
53
|
-
<h1 class="page-title">Overview</h1>
|
|
54
53
|
<div class="stats">
|
|
55
54
|
${stat('Requests', requestCount.toLocaleString(), 'Total recorded')}
|
|
56
55
|
${stat('P95 Latency', formatDuration(p95), 'Response time')}
|
|
@@ -16,7 +16,6 @@ export function renderPerformancePage(metrics: MetricsCollector, basePath: strin
|
|
|
16
16
|
const errorRate = totalRequests > 0 ? ((totalErrors / totalRequests) * 100).toFixed(1) + '%' : '0%'
|
|
17
17
|
|
|
18
18
|
const content = `
|
|
19
|
-
<h1 class="page-title">Performance</h1>
|
|
20
19
|
|
|
21
20
|
<div class="card mb">
|
|
22
21
|
<div class="card-title">Latency</div>
|
|
@@ -30,7 +30,6 @@ export async function renderQueriesPage(store: HeartbeatStore, basePath: string)
|
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
const content = `
|
|
33
|
-
<h1 class="page-title">Queries</h1>
|
|
34
33
|
<div class="stats">
|
|
35
34
|
${stat('Total', entries.length.toString())}
|
|
36
35
|
${stat('Slow', slowCount.toString(), '> threshold')}
|
|
@@ -51,24 +51,24 @@ export async function renderRequestDetailPage(store: HeartbeatStore, uuid: strin
|
|
|
51
51
|
const mdEscaped = md.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')
|
|
52
52
|
|
|
53
53
|
const content = `
|
|
54
|
-
<div style="display:flex;align-items:center;gap:12px;margin-bottom:
|
|
55
|
-
<a href="${basePath}/requests" style="color:var(--fg-3);text-decoration:none;font-size:13px">← Requests</a>
|
|
56
|
-
<div style="margin-left:auto">
|
|
54
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
|
55
|
+
<a href="${basePath}/requests" style="color:var(--fg-3);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">← <span>Requests</span></a>
|
|
56
|
+
<div style="margin-left:auto;display:flex;align-items:center;gap:8px">
|
|
57
57
|
<button class="copy-btn" onclick="copyMd()" title="Copy full request as Markdown">${COPY_ICON}<span>Copy as Markdown</span></button>
|
|
58
58
|
</div>
|
|
59
59
|
</div>
|
|
60
60
|
|
|
61
|
-
<div class="
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
<div class="card mb" style="padding:16px 18px">
|
|
62
|
+
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
|
63
|
+
<code style="color:var(--fg-1);font-size:14px;font-weight:600;letter-spacing:-.01em">${escapeHtml(requestLine)}</code>
|
|
64
|
+
${statusBadge(c.status)}
|
|
65
|
+
${durationBadge(c.duration)}
|
|
66
|
+
<span class="sm dim" style="flex-shrink:0">${timeStr}</span>
|
|
67
|
+
<button class="copy-btn" onclick="copyText('${escapeHtml(requestLine)}', this)" title="Copy request" style="margin-left:auto">${COPY_ICON}</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div style="margin-top:8px">
|
|
70
|
+
<code class="sm dim" style="font-size:11px">${entry.uuid}</code>
|
|
71
|
+
</div>
|
|
72
72
|
</div>
|
|
73
73
|
|
|
74
74
|
<div class="stats">
|
|
@@ -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
|
|
|
@@ -51,6 +52,7 @@ export function renderLayout(options: {
|
|
|
51
52
|
</aside>
|
|
52
53
|
<main>
|
|
53
54
|
<div class="topbar">
|
|
55
|
+
<h1 class="page-title">${title}</h1>
|
|
54
56
|
<button class="theme-btn" onclick="toggleTheme()" title="Toggle theme">${ICONS.moon}</button>
|
|
55
57
|
</div>
|
|
56
58
|
${content}
|
|
@@ -84,6 +86,7 @@ const ICONS = {
|
|
|
84
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>`,
|
|
85
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>`,
|
|
86
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>`,
|
|
87
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>`,
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -156,8 +159,8 @@ body{
|
|
|
156
159
|
|
|
157
160
|
/* Main */
|
|
158
161
|
main{margin-left:200px;flex:1;padding:24px 28px;max-width:1200px;position:relative}
|
|
159
|
-
.topbar{display:flex;justify-content:
|
|
160
|
-
.page-title{font-size:18px;font-weight:600;letter-spacing:-.02em;
|
|
162
|
+
.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
|
|
163
|
+
.page-title{font-size:18px;font-weight:600;letter-spacing:-.02em;color:var(--fg-0);margin:0}
|
|
161
164
|
|
|
162
165
|
/* Cards */
|
|
163
166
|
.card{
|
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
|
+
}
|