@schedulespark/observability 0.0.2-beta-1-0-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 (115) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +384 -0
  4. package/SECURITY_REVIEW.md +90 -0
  5. package/dist/alerts/dispatch.d.ts +6 -0
  6. package/dist/alerts/dispatch.js +11 -0
  7. package/dist/alerts/email.d.ts +37 -0
  8. package/dist/alerts/email.js +29 -0
  9. package/dist/alerts/format.d.ts +12 -0
  10. package/dist/alerts/format.js +30 -0
  11. package/dist/alerts/index.d.ts +8 -0
  12. package/dist/alerts/index.js +4 -0
  13. package/dist/alerts/notifier.d.ts +15 -0
  14. package/dist/alerts/notifier.js +31 -0
  15. package/dist/alerts/spike.d.ts +25 -0
  16. package/dist/alerts/spike.js +41 -0
  17. package/dist/alerts/types.d.ts +38 -0
  18. package/dist/alerts/types.js +1 -0
  19. package/dist/alerts/webhook.d.ts +14 -0
  20. package/dist/alerts/webhook.js +59 -0
  21. package/dist/browser/client.d.ts +26 -0
  22. package/dist/browser/client.js +131 -0
  23. package/dist/browser/index.d.ts +3 -0
  24. package/dist/browser/index.js +1 -0
  25. package/dist/cli/bin.d.ts +2 -0
  26. package/dist/cli/bin.js +6 -0
  27. package/dist/cli/index.d.ts +10 -0
  28. package/dist/cli/index.js +45 -0
  29. package/dist/cli/migrate.d.ts +6 -0
  30. package/dist/cli/migrate.js +14 -0
  31. package/dist/cli/options.d.ts +17 -0
  32. package/dist/cli/options.js +28 -0
  33. package/dist/cli/projects.d.ts +6 -0
  34. package/dist/cli/projects.js +52 -0
  35. package/dist/cli/prune.d.ts +8 -0
  36. package/dist/cli/prune.js +32 -0
  37. package/dist/cli/rollup.d.ts +6 -0
  38. package/dist/cli/rollup.js +24 -0
  39. package/dist/cli/serve.d.ts +5 -0
  40. package/dist/cli/serve.js +72 -0
  41. package/dist/cli/sourcemap.d.ts +8 -0
  42. package/dist/cli/sourcemap.js +113 -0
  43. package/dist/core/breadcrumbs.d.ts +21 -0
  44. package/dist/core/breadcrumbs.js +20 -0
  45. package/dist/core/fingerprint.d.ts +10 -0
  46. package/dist/core/fingerprint.js +35 -0
  47. package/dist/core/index.d.ts +4 -0
  48. package/dist/core/index.js +4 -0
  49. package/dist/core/types.d.ts +219 -0
  50. package/dist/core/types.js +35 -0
  51. package/dist/dashboard/core.d.ts +67 -0
  52. package/dist/dashboard/core.js +40 -0
  53. package/dist/dashboard/fastify.d.ts +17 -0
  54. package/dist/dashboard/fastify.js +247 -0
  55. package/dist/dashboard/html-secondary.d.ts +15 -0
  56. package/dist/dashboard/html-secondary.js +85 -0
  57. package/dist/dashboard/html-shared.d.ts +14 -0
  58. package/dist/dashboard/html-shared.js +39 -0
  59. package/dist/dashboard/html.d.ts +18 -0
  60. package/dist/dashboard/html.js +161 -0
  61. package/dist/dashboard/index.d.ts +5 -0
  62. package/dist/dashboard/index.js +3 -0
  63. package/dist/node/client.d.ts +38 -0
  64. package/dist/node/client.js +191 -0
  65. package/dist/node/exception.d.ts +15 -0
  66. package/dist/node/exception.js +33 -0
  67. package/dist/node/fastify.d.ts +7 -0
  68. package/dist/node/fastify.js +20 -0
  69. package/dist/node/index.d.ts +11 -0
  70. package/dist/node/index.js +5 -0
  71. package/dist/node/logs.d.ts +23 -0
  72. package/dist/node/logs.js +90 -0
  73. package/dist/node/metrics.d.ts +27 -0
  74. package/dist/node/metrics.js +18 -0
  75. package/dist/node/prisma.d.ts +63 -0
  76. package/dist/node/prisma.js +53 -0
  77. package/dist/node/queue.d.ts +25 -0
  78. package/dist/node/queue.js +42 -0
  79. package/dist/node/redact.d.ts +7 -0
  80. package/dist/node/redact.js +26 -0
  81. package/dist/node/tracing.d.ts +28 -0
  82. package/dist/node/tracing.js +49 -0
  83. package/dist/storage/comments.d.ts +13 -0
  84. package/dist/storage/comments.js +20 -0
  85. package/dist/storage/index.d.ts +31 -0
  86. package/dist/storage/index.js +33 -0
  87. package/dist/storage/logs.d.ts +19 -0
  88. package/dist/storage/logs.js +35 -0
  89. package/dist/storage/metrics-ingest.d.ts +14 -0
  90. package/dist/storage/metrics-ingest.js +16 -0
  91. package/dist/storage/metrics-rollup.d.ts +38 -0
  92. package/dist/storage/metrics-rollup.js +80 -0
  93. package/dist/storage/metrics.d.ts +7 -0
  94. package/dist/storage/metrics.js +10 -0
  95. package/dist/storage/migrations.d.ts +17 -0
  96. package/dist/storage/migrations.js +238 -0
  97. package/dist/storage/pool.d.ts +30 -0
  98. package/dist/storage/pool.js +28 -0
  99. package/dist/storage/projects.d.ts +17 -0
  100. package/dist/storage/projects.js +31 -0
  101. package/dist/storage/queries.d.ts +48 -0
  102. package/dist/storage/queries.js +146 -0
  103. package/dist/storage/retention.d.ts +28 -0
  104. package/dist/storage/retention.js +29 -0
  105. package/dist/storage/rows.d.ts +151 -0
  106. package/dist/storage/rows.js +134 -0
  107. package/dist/storage/saved-views.d.ts +15 -0
  108. package/dist/storage/saved-views.js +28 -0
  109. package/dist/storage/schema-ident.d.ts +7 -0
  110. package/dist/storage/schema-ident.js +13 -0
  111. package/dist/storage/spans.d.ts +14 -0
  112. package/dist/storage/spans.js +21 -0
  113. package/docs/screenshots/dashboard.jpg +0 -0
  114. package/docs/standalone-example.md +110 -0
  115. package/package.json +102 -0
@@ -0,0 +1,40 @@
1
+ import { createNotifier } from "../alerts/index.js";
2
+ import { captureInputSchema, DEFAULT_PROJECT_ID } from "../core/index.js";
3
+ import { addComment, assignIssue, createSavedView, deleteSavedView, findProjectByApiKey, getIssueWithEvents, listIssues, listLogs, listMetricRollups, listProjects, listSavedViews, listTransactions, updateIssueStatus } from "../storage/index.js";
4
+ /**
5
+ * Builds the dashboard operations bound to a storage handle. Ingested events notify
6
+ * any configured alert channels the same way the Node SDK does, so alerts fire
7
+ * regardless of whether an event arrived in-process or over HTTP.
8
+ */
9
+ export function createDashboard(storage, options = {}) {
10
+ const notifier = createNotifier(options.channels ?? []);
11
+ return {
12
+ async ingest(payload, projectId) {
13
+ const parsed = captureInputSchema.safeParse(payload);
14
+ if (!parsed.success) {
15
+ return { ok: false, error: parsed.error.message };
16
+ }
17
+ await notifier.recordEvent(storage, parsed.data, projectId);
18
+ return { ok: true };
19
+ },
20
+ listIssues: (filters) => listIssues(storage, filters),
21
+ getIssue: (id) => getIssueWithEvents(storage, id),
22
+ updateIssueStatus: (id, status) => updateIssueStatus(storage, id, status),
23
+ assignIssue: (id, assignee) => assignIssue(storage, id, assignee),
24
+ addComment: (id, input) => addComment(storage, id, input),
25
+ listTransactions: () => listTransactions(storage),
26
+ listProjects: () => listProjects(storage),
27
+ async resolveProjectId(apiKey) {
28
+ if (!apiKey) {
29
+ return DEFAULT_PROJECT_ID;
30
+ }
31
+ const project = await findProjectByApiKey(storage, apiKey);
32
+ return project?.id ?? DEFAULT_PROJECT_ID;
33
+ },
34
+ listSavedViews: () => listSavedViews(storage),
35
+ createSavedView: (name, filters) => createSavedView(storage, name, filters),
36
+ deleteSavedView: (id) => deleteSavedView(storage, id),
37
+ listLogs: (filters) => listLogs(storage, filters),
38
+ listMetricRollups: (filters) => listMetricRollups(storage, filters)
39
+ };
40
+ }
@@ -0,0 +1,17 @@
1
+ import type { Dashboard } from "./core.js";
2
+ import type { FastifyInstance, FastifyRequest } from "fastify";
3
+ /**
4
+ * Options for mounting the dashboard into a Fastify instance.
5
+ */
6
+ export interface FastifyDashboardOptions {
7
+ prefix?: string;
8
+ authorize?: (request: FastifyRequest) => boolean | Promise<boolean>;
9
+ ingestKey?: string;
10
+ }
11
+ /**
12
+ * Registers the ingestion endpoint and the HTML dashboard (issue list, issue detail,
13
+ * status/assignee updates, comments) on a Fastify instance. Can be mounted at any
14
+ * prefix inside a host app's own server, or on a bare Fastify instance for standalone
15
+ * mode.
16
+ */
17
+ export declare function registerDashboard(app: FastifyInstance, dashboard: Dashboard, options?: FastifyDashboardOptions): void;
@@ -0,0 +1,247 @@
1
+ import { renderLogsPage, renderMetricsPage, renderTransactionsPage } from "./html-secondary.js";
2
+ import { renderIssuePage, renderIssuesPage } from "./html.js";
3
+ const ISSUE_STATUSES = ["unresolved", "resolved", "ignored"];
4
+ const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
5
+ const METRIC_BUCKET_SIZES = ["hour", "day"];
6
+ const BEARER_PREFIX = "Bearer ";
7
+ /**
8
+ * Registers the ingestion endpoint and the HTML dashboard (issue list, issue detail,
9
+ * status/assignee updates, comments) on a Fastify instance. Can be mounted at any
10
+ * prefix inside a host app's own server, or on a bare Fastify instance for standalone
11
+ * mode.
12
+ */
13
+ export function registerDashboard(app, dashboard, options = {}) {
14
+ const ctx = { dashboard, options, basePath: options.prefix ?? "" };
15
+ app.post(`${ctx.basePath}/ingest`, async (request, reply) => {
16
+ if (!guardIngest(ctx, request, reply)) {
17
+ return;
18
+ }
19
+ const projectId = await dashboard.resolveProjectId(extractBearerToken(request));
20
+ const result = await dashboard.ingest(request.body, projectId);
21
+ await reply.status(result.ok ? 202 : 400).send(result);
22
+ });
23
+ app.get(ctx.basePath === "" ? "/" : ctx.basePath, (request, reply) => handleIssuesPage(ctx, request, reply));
24
+ app.get(`${ctx.basePath}/transactions`, (request, reply) => handleTransactionsPage(ctx, request, reply));
25
+ app.get(`${ctx.basePath}/logs`, (request, reply) => handleLogsPage(ctx, request, reply));
26
+ app.get(`${ctx.basePath}/metrics`, (request, reply) => handleMetricsPage(ctx, request, reply));
27
+ app.get(`${ctx.basePath}/issues/:id`, (request, reply) => handleIssuePage(ctx, request, reply));
28
+ app.post(`${ctx.basePath}/issues/:id/status`, (request, reply) => handleStatusUpdate(ctx, request, reply));
29
+ app.post(`${ctx.basePath}/issues/:id/assign`, (request, reply) => handleAssign(ctx, request, reply));
30
+ app.post(`${ctx.basePath}/issues/:id/comments`, (request, reply) => handleAddComment(ctx, request, reply));
31
+ app.post(`${ctx.basePath}/views`, (request, reply) => handleCreateSavedView(ctx, request, reply));
32
+ app.post(`${ctx.basePath}/views/:id/delete`, (request, reply) => handleDeleteSavedView(ctx, request, reply));
33
+ app.get(`${ctx.basePath}/api/issues`, async (request, reply) => {
34
+ if (!(await guard(ctx, request, reply)))
35
+ return;
36
+ const { project } = request.query;
37
+ await reply.send(await dashboard.listIssues({ projectId: project }));
38
+ });
39
+ }
40
+ /**
41
+ * Handles `GET /` (or the configured prefix): renders the searchable issues list.
42
+ */
43
+ async function handleIssuesPage(ctx, request, reply) {
44
+ if (!(await guard(ctx, request, reply)))
45
+ return;
46
+ const { q, project, status } = request.query;
47
+ const statusFilter = isIssueStatus(status) ? status : undefined;
48
+ const [issues, projects, savedViews] = await Promise.all([
49
+ ctx.dashboard.listIssues({ q, projectId: project, status: statusFilter }),
50
+ ctx.dashboard.listProjects(),
51
+ ctx.dashboard.listSavedViews()
52
+ ]);
53
+ await reply.type("text/html").send(renderIssuesPage(issues, ctx.basePath, {
54
+ q,
55
+ projects,
56
+ selectedProjectId: project,
57
+ savedViews,
58
+ currentFilters: { q, projectId: project, status: statusFilter }
59
+ }));
60
+ }
61
+ /**
62
+ * Handles `GET /transactions`: renders the recorded root-span (transaction) list.
63
+ */
64
+ async function handleTransactionsPage(ctx, request, reply) {
65
+ if (!(await guard(ctx, request, reply)))
66
+ return;
67
+ const transactions = await ctx.dashboard.listTransactions();
68
+ await reply.type("text/html").send(renderTransactionsPage(transactions, ctx.basePath));
69
+ }
70
+ /**
71
+ * Handles `GET /logs`: renders the structured log list, optionally filtered by
72
+ * `?level=`.
73
+ */
74
+ async function handleLogsPage(ctx, request, reply) {
75
+ if (!(await guard(ctx, request, reply)))
76
+ return;
77
+ const { level } = request.query;
78
+ const levelFilter = isLogLevel(level) ? level : undefined;
79
+ const logs = await ctx.dashboard.listLogs({ level: levelFilter });
80
+ await reply.type("text/html").send(renderLogsPage(logs, ctx.basePath, levelFilter));
81
+ }
82
+ /**
83
+ * Type guard for a valid `LogLevel` string.
84
+ */
85
+ function isLogLevel(value) {
86
+ return typeof value === "string" && LOG_LEVELS.includes(value);
87
+ }
88
+ /**
89
+ * Handles `GET /metrics`: renders the metric rollups table, optionally filtered by
90
+ * `?bucket=`.
91
+ */
92
+ async function handleMetricsPage(ctx, request, reply) {
93
+ if (!(await guard(ctx, request, reply)))
94
+ return;
95
+ const { bucket } = request.query;
96
+ const bucketFilter = isMetricBucketSize(bucket) ? bucket : undefined;
97
+ const rollups = await ctx.dashboard.listMetricRollups({ bucketSize: bucketFilter });
98
+ await reply.type("text/html").send(renderMetricsPage(rollups, ctx.basePath, bucketFilter));
99
+ }
100
+ /**
101
+ * Type guard for a valid `MetricBucketSize` string.
102
+ */
103
+ function isMetricBucketSize(value) {
104
+ return typeof value === "string" && METRIC_BUCKET_SIZES.includes(value);
105
+ }
106
+ /**
107
+ * Handles `GET /issues/:id`: renders a single issue's detail page.
108
+ */
109
+ async function handleIssuePage(ctx, request, reply) {
110
+ if (!(await guard(ctx, request, reply)))
111
+ return;
112
+ const { id } = request.params;
113
+ const detail = await ctx.dashboard.getIssue(id);
114
+ if (!detail) {
115
+ await reply.status(404).send();
116
+ return;
117
+ }
118
+ await reply
119
+ .type("text/html")
120
+ .send(renderIssuePage(detail.issue, detail.events, detail.comments, ctx.basePath));
121
+ }
122
+ /**
123
+ * Handles `POST /issues/:id/status`: updates an issue's workflow status.
124
+ */
125
+ async function handleStatusUpdate(ctx, request, reply) {
126
+ if (!(await guard(ctx, request, reply)))
127
+ return;
128
+ const { id } = request.params;
129
+ const { status } = request.body;
130
+ if (!isIssueStatus(status)) {
131
+ await reply.status(400).send();
132
+ return;
133
+ }
134
+ await ctx.dashboard.updateIssueStatus(id, status);
135
+ await reply.redirect(`${ctx.basePath}/issues/${id}`);
136
+ }
137
+ /**
138
+ * Handles `POST /issues/:id/assign`: sets (or clears, on an empty value) an issue's
139
+ * assignee.
140
+ */
141
+ async function handleAssign(ctx, request, reply) {
142
+ if (!(await guard(ctx, request, reply)))
143
+ return;
144
+ const { id } = request.params;
145
+ const { assignee } = request.body;
146
+ await ctx.dashboard.assignIssue(id, toAssigneeOrNull(assignee));
147
+ await reply.redirect(`${ctx.basePath}/issues/${id}`);
148
+ }
149
+ /**
150
+ * Treats an empty assignee field as "unassign" rather than storing an empty string.
151
+ */
152
+ function toAssigneeOrNull(assignee) {
153
+ return assignee && assignee.length > 0 ? assignee : null;
154
+ }
155
+ /**
156
+ * Handles `POST /issues/:id/comments`: adds a comment to an issue.
157
+ */
158
+ async function handleAddComment(ctx, request, reply) {
159
+ if (!(await guard(ctx, request, reply)))
160
+ return;
161
+ const { id } = request.params;
162
+ const { author, body } = request.body;
163
+ if (!author || !body) {
164
+ await reply.status(400).send();
165
+ return;
166
+ }
167
+ await ctx.dashboard.addComment(id, { author, body });
168
+ await reply.redirect(`${ctx.basePath}/issues/${id}`);
169
+ }
170
+ /**
171
+ * Handles `POST /views`: saves the current issues-list filters under a name.
172
+ */
173
+ async function handleCreateSavedView(ctx, request, reply) {
174
+ if (!(await guard(ctx, request, reply)))
175
+ return;
176
+ const { name, q, project, status } = request.body;
177
+ if (!name) {
178
+ await reply.status(400).send();
179
+ return;
180
+ }
181
+ await ctx.dashboard.createSavedView(name, {
182
+ q: toNonEmptyOrUndefined(q),
183
+ projectId: toNonEmptyOrUndefined(project),
184
+ status: isIssueStatus(status) ? status : undefined
185
+ });
186
+ await reply.redirect(ctx.basePath === "" ? "/" : ctx.basePath);
187
+ }
188
+ /**
189
+ * Treats an empty string the same as "not provided" — used when persisting a saved
190
+ * view's filters, so an unused filter field is omitted rather than stored as `""`.
191
+ */
192
+ function toNonEmptyOrUndefined(value) {
193
+ return value && value.length > 0 ? value : undefined;
194
+ }
195
+ /**
196
+ * Handles `POST /views/:id/delete`: removes a saved view.
197
+ */
198
+ async function handleDeleteSavedView(ctx, request, reply) {
199
+ if (!(await guard(ctx, request, reply)))
200
+ return;
201
+ const { id } = request.params;
202
+ await ctx.dashboard.deleteSavedView(id);
203
+ await reply.redirect(ctx.basePath === "" ? "/" : ctx.basePath);
204
+ }
205
+ /**
206
+ * Runs the caller-supplied `authorize` hook, sending a 401 and returning `false` when
207
+ * it denies the request. Requests are allowed through when no hook is configured.
208
+ */
209
+ async function guard(ctx, request, reply) {
210
+ const allowed = ctx.options.authorize ? await ctx.options.authorize(request) : true;
211
+ if (!allowed) {
212
+ await reply.status(401).send();
213
+ }
214
+ return allowed;
215
+ }
216
+ /**
217
+ * Checks the ingestion request's `Authorization: Bearer <ingestKey>` header against
218
+ * the configured key — the same scheme the browser SDK's `apiKey` option and the
219
+ * standalone CLI's `--token` already use — sending a 401 and returning `false` on a
220
+ * mismatch. With no `ingestKey` configured, every request is allowed, matching the
221
+ * package's zero-config default; deployers opt into locking it down.
222
+ */
223
+ function guardIngest(ctx, request, reply) {
224
+ if (!ctx.options.ingestKey) {
225
+ return true;
226
+ }
227
+ if (extractBearerToken(request) !== ctx.options.ingestKey) {
228
+ reply.status(401).send({ ok: false, error: "missing or invalid ingestion key" });
229
+ return false;
230
+ }
231
+ return true;
232
+ }
233
+ /**
234
+ * Extracts the `Authorization: Bearer <token>` header value, if present. Used both
235
+ * for the global `ingestKey` check above and, separately, to resolve which project an
236
+ * ingested event belongs to by its own per-project API key.
237
+ */
238
+ function extractBearerToken(request) {
239
+ const header = request.headers.authorization;
240
+ return header?.startsWith(BEARER_PREFIX) ? header.slice(BEARER_PREFIX.length) : undefined;
241
+ }
242
+ /**
243
+ * Type guard for a valid `IssueStatus` string.
244
+ */
245
+ function isIssueStatus(value) {
246
+ return typeof value === "string" && ISSUE_STATUSES.includes(value);
247
+ }
@@ -0,0 +1,15 @@
1
+ import type { LogEntry, MetricRollup, RecordedSpan } from "../core/index.js";
2
+ /**
3
+ * Renders the transactions list page (root spans only, no drill-down into child
4
+ * spans yet).
5
+ */
6
+ export declare function renderTransactionsPage(transactions: RecordedSpan[], basePath: string): string;
7
+ /**
8
+ * Renders the structured log list, optionally filtered by `?level=`.
9
+ */
10
+ export declare function renderLogsPage(logs: LogEntry[], basePath: string, selectedLevel?: string): string;
11
+ /**
12
+ * Renders the metric rollups list: the latest bucket per name/tags combination,
13
+ * optionally filtered by `?bucket=`.
14
+ */
15
+ export declare function renderMetricsPage(rollups: MetricRollup[], basePath: string, selectedBucket?: string): string;
@@ -0,0 +1,85 @@
1
+ import { escapeHtml, page } from "./html-shared.js";
2
+ /**
3
+ * Renders the transactions list page (root spans only, no drill-down into child
4
+ * spans yet).
5
+ */
6
+ export function renderTransactionsPage(transactions, basePath) {
7
+ const rows = transactions.map((tx) => renderTransactionRow(tx)).join("");
8
+ return page("Transactions", `<p><a href="${basePath}">&larr; issues</a></p><h1>Transactions</h1><table><thead><tr><th>Name</th><th>Status</th><th>Duration (ms)</th><th>Started</th></tr></thead><tbody>${rows}</tbody></table>`);
9
+ }
10
+ /**
11
+ * Renders a single row of the transactions list table.
12
+ */
13
+ function renderTransactionRow(tx) {
14
+ const statusClass = tx.status === "error" ? "badge-error" : "badge-info";
15
+ return `<tr>
16
+ <td>${escapeHtml(tx.name)}</td>
17
+ <td><span class="badge ${statusClass}">${escapeHtml(tx.status)}</span></td>
18
+ <td>${String(tx.durationMs)}</td>
19
+ <td>${escapeHtml(tx.startedAt)}</td>
20
+ </tr>`;
21
+ }
22
+ const LOG_LEVEL_OPTIONS = ["trace", "debug", "info", "warn", "error", "fatal"];
23
+ const LOG_LEVEL_BADGE_CLASS = {
24
+ trace: "badge-info",
25
+ debug: "badge-info",
26
+ info: "badge-info",
27
+ warn: "badge-warning",
28
+ error: "badge-error",
29
+ fatal: "badge-error"
30
+ };
31
+ /**
32
+ * Renders the structured log list, optionally filtered by `?level=`.
33
+ */
34
+ export function renderLogsPage(logs, basePath, selectedLevel) {
35
+ const rows = logs.map((entry) => renderLogRow(entry)).join("");
36
+ const levelFilter = `<form method="get" action="${basePath}/logs">
37
+ <select name="level" onchange="this.form.submit()">
38
+ <option value="">Any level</option>
39
+ ${LOG_LEVEL_OPTIONS.map((level) => `<option value="${level}"${level === selectedLevel ? " selected" : ""}>${level}</option>`).join("")}
40
+ </select>
41
+ </form>`;
42
+ return page("Logs", `<p><a href="${basePath}">&larr; issues</a></p><h1>Logs</h1>${levelFilter}<table><thead><tr><th>Level</th><th>Message</th><th>Logged at</th></tr></thead><tbody>${rows}</tbody></table>`);
43
+ }
44
+ /**
45
+ * Renders a single row of the logs table.
46
+ */
47
+ function renderLogRow(entry) {
48
+ const badgeClass = LOG_LEVEL_BADGE_CLASS[entry.level] ?? "badge-info";
49
+ const context = Object.keys(entry.context).length > 0 ? `<pre>${escapeHtml(JSON.stringify(entry.context))}</pre>` : "";
50
+ return `<tr>
51
+ <td><span class="badge ${badgeClass}">${escapeHtml(entry.level)}</span></td>
52
+ <td>${escapeHtml(entry.message)}${context}</td>
53
+ <td>${escapeHtml(entry.loggedAt)}</td>
54
+ </tr>`;
55
+ }
56
+ /**
57
+ * Renders the metric rollups list: the latest bucket per name/tags combination,
58
+ * optionally filtered by `?bucket=`.
59
+ */
60
+ export function renderMetricsPage(rollups, basePath, selectedBucket) {
61
+ const rows = rollups.map((rollup) => renderMetricRollupRow(rollup)).join("");
62
+ const bucketFilter = `<form method="get" action="${basePath}/metrics">
63
+ <select name="bucket" onchange="this.form.submit()">
64
+ <option value="">Any bucket size</option>
65
+ <option value="hour"${selectedBucket === "hour" ? " selected" : ""}>hour</option>
66
+ <option value="day"${selectedBucket === "day" ? " selected" : ""}>day</option>
67
+ </select>
68
+ </form>`;
69
+ return page("Metrics", `<p><a href="${basePath}">&larr; issues</a></p><h1>Metrics</h1>${bucketFilter}<table><thead><tr><th>Name</th><th>Kind</th><th>Bucket</th><th>Sum</th><th>Count</th><th>Min</th><th>Max</th><th>Avg</th></tr></thead><tbody>${rows}</tbody></table>`);
70
+ }
71
+ /**
72
+ * Renders a single row of the metric rollups table.
73
+ */
74
+ function renderMetricRollupRow(rollup) {
75
+ return `<tr>
76
+ <td>${escapeHtml(rollup.name)}</td>
77
+ <td>${escapeHtml(rollup.kind)}</td>
78
+ <td>${escapeHtml(rollup.bucketStart)} (${escapeHtml(rollup.bucketSize)})</td>
79
+ <td>${String(rollup.sum)}</td>
80
+ <td>${String(rollup.count)}</td>
81
+ <td>${String(rollup.min)}</td>
82
+ <td>${String(rollup.max)}</td>
83
+ <td>${rollup.avg.toFixed(2)}</td>
84
+ </tr>`;
85
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Escapes HTML-significant characters. Every captured field (message, stack trace,
3
+ * title) is untrusted, attacker-reachable input, so it must always pass through this
4
+ * before being interpolated into a rendered page.
5
+ */
6
+ export declare function escapeHtml(value: string): string;
7
+ /**
8
+ * Wraps page content in a minimal shared HTML shell.
9
+ */
10
+ export declare function page(title: string, body: string): string;
11
+ /**
12
+ * Renders a small colored badge for an event/issue level.
13
+ */
14
+ export declare function levelBadge(level: string): string;
@@ -0,0 +1,39 @@
1
+ const HTML_ESCAPES = {
2
+ "&": "&amp;",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;"
7
+ };
8
+ /**
9
+ * Escapes HTML-significant characters. Every captured field (message, stack trace,
10
+ * title) is untrusted, attacker-reachable input, so it must always pass through this
11
+ * before being interpolated into a rendered page.
12
+ */
13
+ export function escapeHtml(value) {
14
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
15
+ }
16
+ const PAGE_STYLE = `
17
+ body { font: 14px/1.5 -apple-system, sans-serif; margin: 2rem; color: #1a1a1a; }
18
+ table { border-collapse: collapse; width: 100%; }
19
+ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #e5e5e5; }
20
+ a { color: #4f46e5; text-decoration: none; }
21
+ pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; white-space: pre-wrap; }
22
+ .badge { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 12px; }
23
+ .badge-error { background: #fee2e2; color: #991b1b; }
24
+ .badge-warning { background: #fef3c7; color: #92400e; }
25
+ .badge-info { background: #e0e7ff; color: #3730a3; }
26
+ .comment { border-top: 1px solid #e5e5e5; padding: 0.75rem 0; }
27
+ `;
28
+ /**
29
+ * Wraps page content in a minimal shared HTML shell.
30
+ */
31
+ export function page(title, body) {
32
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${PAGE_STYLE}</style></head><body>${body}</body></html>`;
33
+ }
34
+ /**
35
+ * Renders a small colored badge for an event/issue level.
36
+ */
37
+ export function levelBadge(level) {
38
+ return `<span class="badge badge-${escapeHtml(level)}">${escapeHtml(level)}</span>`;
39
+ }
@@ -0,0 +1,18 @@
1
+ import type { CapturedEvent, Comment, Issue, Project, SavedView, SavedViewFilters } from "../core/index.js";
2
+ export { escapeHtml } from "./html-shared.js";
3
+ /**
4
+ * Renders the issues list page, with a search box (and, once more than one project
5
+ * exists, a project filter) that preserves the current query on submit.
6
+ */
7
+ export declare function renderIssuesPage(issues: Issue[], basePath: string, filters?: {
8
+ q?: string;
9
+ projects?: Project[];
10
+ selectedProjectId?: string;
11
+ savedViews?: SavedView[];
12
+ currentFilters?: SavedViewFilters;
13
+ }): string;
14
+ /**
15
+ * Renders a single issue's detail page: status/assignee controls, recent events, and
16
+ * its comment thread.
17
+ */
18
+ export declare function renderIssuePage(issue: Issue, events: CapturedEvent[], comments: Comment[], basePath: string): string;
@@ -0,0 +1,161 @@
1
+ import { escapeHtml, levelBadge, page } from "./html-shared.js";
2
+ export { escapeHtml } from "./html-shared.js";
3
+ const ISSUE_STATUS_OPTIONS = ["unresolved", "resolved", "ignored"];
4
+ /**
5
+ * Renders the issues list page, with a search box (and, once more than one project
6
+ * exists, a project filter) that preserves the current query on submit.
7
+ */
8
+ export function renderIssuesPage(issues, basePath, filters = {}) {
9
+ const { q, projects = [], selectedProjectId, savedViews = [], currentFilters } = filters;
10
+ const rows = issues.map((issue) => renderIssueRow(issue, basePath)).join("");
11
+ const projectFilter = projects.length > 1 ? renderProjectFilter(projects, selectedProjectId) : "";
12
+ const searchBox = `<form method="get" action="${basePath}">
13
+ <input type="search" name="q" placeholder="Search titles…" value="${escapeHtml(q ?? "")}">
14
+ ${renderStatusFilter(currentFilters?.status)}
15
+ ${projectFilter}
16
+ <button type="submit">Search</button>
17
+ </form>`;
18
+ const savedViewsBlock = renderSavedViewsBlock(savedViews, basePath, currentFilters);
19
+ return page("Issues", `<h1>Issues</h1><p><a href="${basePath}/transactions">Transactions &rarr;</a> · <a href="${basePath}/logs">Logs &rarr;</a> · <a href="${basePath}/metrics">Metrics &rarr;</a></p>${savedViewsBlock}${searchBox}<table><thead><tr><th>Level</th><th>Title</th><th>Status</th><th>Assignee</th><th>Events</th><th>Last seen</th></tr></thead><tbody>${rows}</tbody></table>`);
20
+ }
21
+ /**
22
+ * Renders the `?status=` dropdown on the issues list search form.
23
+ */
24
+ function renderStatusFilter(selectedStatus) {
25
+ const options = ISSUE_STATUS_OPTIONS.map((status) => `<option value="${status}"${status === selectedStatus ? " selected" : ""}>${status}</option>`).join("");
26
+ return `<select name="status"><option value="">Any status</option>${options}</select>`;
27
+ }
28
+ /**
29
+ * Renders the saved-views list (each entry a link that applies its filters, plus a
30
+ * delete button) and the "save this search" form, which carries the currently active
31
+ * filters as hidden fields.
32
+ */
33
+ function renderSavedViewsBlock(savedViews, basePath, currentFilters) {
34
+ const list = savedViews.length > 0
35
+ ? `<ul>${savedViews.map((view) => renderSavedViewItem(view, basePath)).join("")}</ul>`
36
+ : "";
37
+ return `<div><strong>Saved views</strong>${list}${renderSaveViewForm(basePath, currentFilters)}</div>`;
38
+ }
39
+ /**
40
+ * Renders a single saved view: a link applying its filters, and a delete button.
41
+ */
42
+ function renderSavedViewItem(view, basePath) {
43
+ const params = new URLSearchParams();
44
+ if (view.filters.q) {
45
+ params.set("q", view.filters.q);
46
+ }
47
+ if (view.filters.projectId) {
48
+ params.set("project", view.filters.projectId);
49
+ }
50
+ if (view.filters.status) {
51
+ params.set("status", view.filters.status);
52
+ }
53
+ const query = params.toString();
54
+ return `<li><a href="${basePath}${query ? `?${query}` : ""}">${escapeHtml(view.name)}</a>
55
+ <form method="post" action="${basePath}/views/${escapeHtml(view.id)}/delete" style="display:inline">
56
+ <button type="submit">delete</button>
57
+ </form>
58
+ </li>`;
59
+ }
60
+ /**
61
+ * Renders the "save this search" form, carrying the current `q`/`project`/`status`
62
+ * filters as hidden fields so saving doesn't require re-entering them.
63
+ */
64
+ function renderSaveViewForm(basePath, currentFilters) {
65
+ return `<form method="post" action="${basePath}/views">
66
+ <input type="text" name="name" placeholder="Save this search as…" required>
67
+ <input type="hidden" name="q" value="${escapeHtml(currentFilters?.q ?? "")}">
68
+ <input type="hidden" name="project" value="${escapeHtml(currentFilters?.projectId ?? "")}">
69
+ <input type="hidden" name="status" value="${escapeHtml(currentFilters?.status ?? "")}">
70
+ <button type="submit">Save view</button>
71
+ </form>`;
72
+ }
73
+ /**
74
+ * Renders the `?project=` dropdown, populated from every configured project.
75
+ */
76
+ function renderProjectFilter(projects, selectedProjectId) {
77
+ const options = projects
78
+ .map((project) => `<option value="${escapeHtml(project.id)}"${project.id === selectedProjectId ? " selected" : ""}>${escapeHtml(project.name)}</option>`)
79
+ .join("");
80
+ return `<select name="project"><option value="">All projects</option>${options}</select>`;
81
+ }
82
+ /**
83
+ * Renders a single row of the issues list table.
84
+ */
85
+ function renderIssueRow(issue, basePath) {
86
+ return `<tr>
87
+ <td>${levelBadge(issue.level)}</td>
88
+ <td><a href="${basePath}/issues/${escapeHtml(issue.id)}">${escapeHtml(issue.title)}</a></td>
89
+ <td>${escapeHtml(issue.status)}</td>
90
+ <td>${escapeHtml(issue.assignee ?? "—")}</td>
91
+ <td>${String(issue.eventCount)}</td>
92
+ <td>${escapeHtml(issue.lastSeen)}</td>
93
+ </tr>`;
94
+ }
95
+ /**
96
+ * Renders a single issue's detail page: status/assignee controls, recent events, and
97
+ * its comment thread.
98
+ */
99
+ export function renderIssuePage(issue, events, comments, basePath) {
100
+ const eventsHtml = events.map((event) => renderEvent(event)).join("");
101
+ const body = `<p><a href="${basePath}">&larr; back</a></p>
102
+ <h1>${levelBadge(issue.level)} ${escapeHtml(issue.title)}</h1>
103
+ <p>${renderStatusForm(issue, basePath)}</p>
104
+ <p>${renderAssignForm(issue, basePath)}</p>
105
+ <h2>Events (${String(events.length)})</h2>${eventsHtml}
106
+ <h2>Comments (${String(comments.length)})</h2>${renderComments(comments)}${renderCommentForm(issue, basePath)}`;
107
+ return page(issue.title, body);
108
+ }
109
+ /**
110
+ * Renders the resolve/ignore/unresolve status buttons.
111
+ */
112
+ function renderStatusForm(issue, basePath) {
113
+ return ["unresolved", "resolved", "ignored"]
114
+ .map((status) => `<form method="post" action="${basePath}/issues/${escapeHtml(issue.id)}/status" style="display:inline">
115
+ <input type="hidden" name="status" value="${status}">
116
+ <button type="submit"${status === issue.status ? " disabled" : ""}>${status}</button>
117
+ </form>`)
118
+ .join(" ");
119
+ }
120
+ /**
121
+ * Renders the assignee text field and submit button.
122
+ */
123
+ function renderAssignForm(issue, basePath) {
124
+ return `<form method="post" action="${basePath}/issues/${escapeHtml(issue.id)}/assign" style="display:inline">
125
+ <input type="text" name="assignee" placeholder="Assignee" value="${escapeHtml(issue.assignee ?? "")}">
126
+ <button type="submit">Assign</button>
127
+ </form>`;
128
+ }
129
+ /**
130
+ * Renders the comment thread, or a placeholder when there are no comments yet.
131
+ */
132
+ function renderComments(comments) {
133
+ if (comments.length === 0) {
134
+ return "<p>No comments yet.</p>";
135
+ }
136
+ return comments
137
+ .map((comment) => `<div class="comment"><strong>${escapeHtml(comment.author)}</strong>
138
+ <span>${escapeHtml(comment.createdAt)}</span><p>${escapeHtml(comment.body)}</p></div>`)
139
+ .join("");
140
+ }
141
+ /**
142
+ * Renders the "add a comment" form.
143
+ */
144
+ function renderCommentForm(issue, basePath) {
145
+ return `<form method="post" action="${basePath}/issues/${escapeHtml(issue.id)}/comments">
146
+ <input type="text" name="author" placeholder="Your name" required>
147
+ <br><textarea name="body" placeholder="Add a comment…" required></textarea>
148
+ <br><button type="submit">Comment</button>
149
+ </form>`;
150
+ }
151
+ /**
152
+ * Renders a single event within an issue's detail page.
153
+ */
154
+ function renderEvent(event) {
155
+ const stack = event.stackTrace ? `<pre>${escapeHtml(event.stackTrace)}</pre>` : "";
156
+ const context = `<pre>${escapeHtml(JSON.stringify(event.context, null, 2))}</pre>`;
157
+ const breadcrumbs = event.breadcrumbs.length > 0
158
+ ? `<pre>${escapeHtml(JSON.stringify(event.breadcrumbs, null, 2))}</pre>`
159
+ : "";
160
+ return `<div><p><strong>${escapeHtml(event.capturedAt)}</strong> ${escapeHtml(event.message)}</p>${stack}${context}${breadcrumbs}</div>`;
161
+ }
@@ -0,0 +1,5 @@
1
+ export { createDashboard } from "./core.js";
2
+ export { registerDashboard } from "./fastify.js";
3
+ export { escapeHtml } from "./html.js";
4
+ export type { Dashboard, DashboardOptions, IngestResult, ListIssuesOptions } from "./core.js";
5
+ export type { FastifyDashboardOptions } from "./fastify.js";
@@ -0,0 +1,3 @@
1
+ export { createDashboard } from "./core.js";
2
+ export { registerDashboard } from "./fastify.js";
3
+ export { escapeHtml } from "./html.js";