@zerotal/monitor 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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/package.json +74 -0
- package/src/MonitorStore.ts +1471 -0
- package/src/alerting.ts +173 -0
- package/src/config.ts +171 -0
- package/src/facades/Monitor.ts +75 -0
- package/src/index.ts +62 -0
- package/src/instance.ts +16 -0
- package/src/middleware/MonitorAuthMiddleware.ts +20 -0
- package/src/middleware/MonitorPayloadMiddleware.ts +96 -0
- package/src/panel.ts +159 -0
- package/src/prometheus.ts +128 -0
- package/src/provider/MonitorProvider.ts +318 -0
- package/src/recorder/MonitorEventBridge.ts +157 -0
- package/src/recorder/ctxBuffers.ts +91 -0
- package/src/sources/live.ts +249 -0
- package/src/sources/realtime.ts +24 -0
- package/src/sources/system.ts +151 -0
- package/src/store/MonitorDb.ts +415 -0
- package/src/store/RingBuffer.ts +89 -0
- package/src/store/types.ts +580 -0
- package/src/support/time.ts +25 -0
- package/src/ui/MonitorLayout.tsx +42 -0
- package/src/ui/MonitorPage.tsx +3722 -0
- package/src/ui/charts.tsx +55 -0
- package/src/ui/icons.ts +32 -0
- package/src/ui/tones.ts +78 -0
|
@@ -0,0 +1,3722 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
import { Component, expose, locked, url } from "@zerotal/flow";
|
|
3
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
4
|
+
import type { HttpContext } from "@zerotal/core";
|
|
5
|
+
import { MonitorLayout } from "./MonitorLayout.tsx";
|
|
6
|
+
import { MonitorStore } from "../MonitorStore.ts";
|
|
7
|
+
import { _getStore } from "../instance.ts";
|
|
8
|
+
import { icons } from "./icons.ts";
|
|
9
|
+
import { Sparkline, sparkPoints, areaPaths } from "./charts.tsx";
|
|
10
|
+
import {
|
|
11
|
+
gaugeBar,
|
|
12
|
+
gaugeText,
|
|
13
|
+
methodTone,
|
|
14
|
+
pctTone,
|
|
15
|
+
spanColor,
|
|
16
|
+
statusTone,
|
|
17
|
+
toneText,
|
|
18
|
+
} from "./tones.ts";
|
|
19
|
+
import { ago, commas } from "../support/time.ts";
|
|
20
|
+
import { Card, CardHeader, CardTitle, Table } from "@zerotal/flow-ui";
|
|
21
|
+
import type { TableColumn } from "@zerotal/flow-ui";
|
|
22
|
+
import { MonitorPanel } from "../panel.ts";
|
|
23
|
+
import type {
|
|
24
|
+
MonitorRow,
|
|
25
|
+
MonitorSectionData,
|
|
26
|
+
MonitorStat,
|
|
27
|
+
MonitorTable,
|
|
28
|
+
MonitorTone,
|
|
29
|
+
} from "../panel.ts";
|
|
30
|
+
import type {
|
|
31
|
+
AlertEntry,
|
|
32
|
+
FeedEvent,
|
|
33
|
+
MonitorRange,
|
|
34
|
+
MonitorSnapshot,
|
|
35
|
+
RequestEntry,
|
|
36
|
+
RouteDetail,
|
|
37
|
+
WsAction,
|
|
38
|
+
} from "../store/types.ts";
|
|
39
|
+
|
|
40
|
+
interface NavItem {
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
icon: string;
|
|
44
|
+
badge?: "exceptions" | "queues" | "alerts";
|
|
45
|
+
badgeTone?: "red" | "slate";
|
|
46
|
+
}
|
|
47
|
+
interface NavGroup {
|
|
48
|
+
label: string;
|
|
49
|
+
items: NavItem[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const NAV: NavGroup[] = [
|
|
53
|
+
{
|
|
54
|
+
label: "Monitoring",
|
|
55
|
+
items: [
|
|
56
|
+
{ id: "overview", label: "Overview", icon: icons.overview! },
|
|
57
|
+
{ id: "requests", label: "Requests", icon: icons.requests! },
|
|
58
|
+
{ id: "realtime", label: "Realtime", icon: icons.realtime! },
|
|
59
|
+
{
|
|
60
|
+
id: "exceptions",
|
|
61
|
+
label: "Exceptions",
|
|
62
|
+
icon: icons.exceptions!,
|
|
63
|
+
badge: "exceptions",
|
|
64
|
+
badgeTone: "red",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "alerts",
|
|
68
|
+
label: "Alerts",
|
|
69
|
+
icon: icons.alerts!,
|
|
70
|
+
badge: "alerts",
|
|
71
|
+
badgeTone: "red",
|
|
72
|
+
},
|
|
73
|
+
{ id: "security", label: "Security", icon: icons.security! },
|
|
74
|
+
{ id: "logs", label: "Logs", icon: icons.logs! },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
label: "Jobs & Mail",
|
|
79
|
+
items: [
|
|
80
|
+
{ id: "queues", label: "Queues", icon: icons.queues!, badge: "queues", badgeTone: "slate" },
|
|
81
|
+
{ id: "mail", label: "Mail", icon: icons.mail! },
|
|
82
|
+
{ id: "notifications", label: "Notifications", icon: icons.notifications! },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
label: "Infrastructure",
|
|
87
|
+
items: [
|
|
88
|
+
{ id: "database", label: "Database", icon: icons.database! },
|
|
89
|
+
{ id: "cache", label: "Cache", icon: icons.cache! },
|
|
90
|
+
{ id: "commands", label: "Commands", icon: icons.commands! },
|
|
91
|
+
{ id: "system", label: "System", icon: icons.system! },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const RANGES: MonitorRange[] = ["live", "1h", "24h", "7d"];
|
|
97
|
+
|
|
98
|
+
/** Map a contributed section's semantic tone onto the panel's palette. */
|
|
99
|
+
const SECTION_TONE: Record<MonitorTone, string> = {
|
|
100
|
+
default: "text-foreground",
|
|
101
|
+
good: "text-success",
|
|
102
|
+
warn: "text-warning",
|
|
103
|
+
bad: "text-destructive",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const SECTION_BAR: Record<MonitorTone, string> = {
|
|
107
|
+
default: "bg-muted-foreground",
|
|
108
|
+
good: "bg-success",
|
|
109
|
+
warn: "bg-warning",
|
|
110
|
+
bad: "bg-destructive",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Rows per page in the requests table. */
|
|
114
|
+
const REQ_PER_PAGE = 10;
|
|
115
|
+
|
|
116
|
+
/** Rows per page in the secondary feeds (notifications / commands / security). */
|
|
117
|
+
const FEED_PER_PAGE = 12;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The production monitoring panel — a faithful, server-driven implementation of
|
|
121
|
+
* `super-panel.html` built on Flow. Eight tabs (Overview, Requests,
|
|
122
|
+
* Exceptions, Queues, Mail, Database, Cache, System) render from a live
|
|
123
|
+
* {@link MonitorSnapshot}; interactions round-trip over the Flow WebSocket.
|
|
124
|
+
*/
|
|
125
|
+
export class MonitorPage extends Component {
|
|
126
|
+
static layout = MonitorLayout;
|
|
127
|
+
static title = "Zerotal · Super Panel";
|
|
128
|
+
|
|
129
|
+
// ── UI state ──────────────────────────────────────────────────────────────
|
|
130
|
+
// `tab` is driven by the URL path (/monitor/:section), seeded in onMount().
|
|
131
|
+
@expose tab = "overview";
|
|
132
|
+
@expose @url range: MonitorRange = "live";
|
|
133
|
+
@expose live = true;
|
|
134
|
+
@expose q = "";
|
|
135
|
+
@expose statusFilter = "all";
|
|
136
|
+
@expose activeTag = "all";
|
|
137
|
+
@expose dismissed: number[] = [];
|
|
138
|
+
@expose openRequestId = 0;
|
|
139
|
+
@expose openExc = "";
|
|
140
|
+
@expose openAlert = ""; // expanded alert card (alert id, "" = none)
|
|
141
|
+
@expose openMailId = 0;
|
|
142
|
+
@expose reqPage = 0; // requests table, 10 rows per page (0-indexed)
|
|
143
|
+
@expose openWsAction = 0; // expanded realtime action row (action id, 0 = none)
|
|
144
|
+
@expose logLevel = "all"; // Logs tab level filter
|
|
145
|
+
// Shared search/filter/pagination for the secondary feeds (notifications/commands/
|
|
146
|
+
// security). Safe to share: each tab is a fresh mount, so state resets on navigation.
|
|
147
|
+
@expose feedQ = "";
|
|
148
|
+
@expose feedStatus = "all";
|
|
149
|
+
@expose feedPage = 0;
|
|
150
|
+
// Per-route drill-in: which route is being inspected (empty = none). URL-bound so
|
|
151
|
+
// the detail view survives a refresh and is shareable by link.
|
|
152
|
+
@expose @url routeMethod = "";
|
|
153
|
+
@expose @url routePath = "";
|
|
154
|
+
|
|
155
|
+
// ── Data ──────────────────────────────────────────────────────────────────
|
|
156
|
+
@locked snap!: MonitorSnapshot;
|
|
157
|
+
@locked routeDetail: RouteDetail | null = null;
|
|
158
|
+
/** Content of the active contributed section, resolved alongside the snapshot. */
|
|
159
|
+
@locked sectionData: MonitorSectionData | null = null;
|
|
160
|
+
|
|
161
|
+
// Branding pulled from config at boot.
|
|
162
|
+
@locked brandTitle = "Super Panel";
|
|
163
|
+
@locked brandSubtitle = "Zerotal Ops";
|
|
164
|
+
@locked refreshMs = 3000;
|
|
165
|
+
@locked basePath = "/monitor";
|
|
166
|
+
|
|
167
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
override async onMount(ctx?: HttpContext<Record<string, unknown>>): Promise<void> {
|
|
170
|
+
const cfg = this._config();
|
|
171
|
+
if (cfg) {
|
|
172
|
+
this.brandTitle = cfg.title;
|
|
173
|
+
this.brandSubtitle = cfg.subtitle;
|
|
174
|
+
this.refreshMs = cfg.refreshMs;
|
|
175
|
+
this.basePath = cfg.path;
|
|
176
|
+
}
|
|
177
|
+
// The active page comes from the URL path segment (/monitor/<section>), unless
|
|
178
|
+
// a route drill-in is URL-seeded (?routeMethod=&routePath=), which wins.
|
|
179
|
+
// Route params live on ctx.params (the :section binding), not as a ctx sibling.
|
|
180
|
+
const section = ctx?.params["section"] as string | undefined;
|
|
181
|
+
if (this.routeMethod && this.routePath) {
|
|
182
|
+
this.tab = "route";
|
|
183
|
+
} else if (section && this._tabIds().includes(section)) {
|
|
184
|
+
this.tab = section;
|
|
185
|
+
}
|
|
186
|
+
await this._load();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Valid section ids, derived from the nav — built-in sections plus contributed ones. */
|
|
190
|
+
private _tabIds(): string[] {
|
|
191
|
+
return [
|
|
192
|
+
...NAV.flatMap((g) => g.items.map((i) => i.id)),
|
|
193
|
+
...MonitorPanel.sections().map((s) => s.id),
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The sidebar's groups: the built-in ones, then a group per contributed
|
|
199
|
+
* section heading. Contributed sections sort after the panel's own so a
|
|
200
|
+
* package can't reorder the operator's familiar navigation.
|
|
201
|
+
*/
|
|
202
|
+
private _navGroups(): NavGroup[] {
|
|
203
|
+
const contributed = MonitorPanel.sections();
|
|
204
|
+
if (contributed.length === 0) return NAV;
|
|
205
|
+
|
|
206
|
+
const groups = new Map<string, NavItem[]>();
|
|
207
|
+
for (const s of contributed) {
|
|
208
|
+
const label = s.group ?? "Extensions";
|
|
209
|
+
const items = groups.get(label) ?? [];
|
|
210
|
+
items.push({ id: s.id, label: s.label, icon: s.icon ?? icons.system! });
|
|
211
|
+
groups.set(label, items);
|
|
212
|
+
}
|
|
213
|
+
return [...NAV, ...[...groups].map(([label, items]) => ({ label, items }))];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Build the deep-link URL for a section, carrying a non-live range. */
|
|
217
|
+
private _sectionHref(id: string): string {
|
|
218
|
+
const base = `${this.basePath.replace(/\/$/, "")}/${id}`;
|
|
219
|
+
return this.range && this.range !== "live" ? `${base}?range=${this.range}` : base;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private _store(): MonitorStore {
|
|
223
|
+
return _getStore() ?? new MonitorStore();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private _config(): {
|
|
227
|
+
title: string;
|
|
228
|
+
subtitle: string;
|
|
229
|
+
refreshMs: number;
|
|
230
|
+
path: string;
|
|
231
|
+
retentionDays?: number;
|
|
232
|
+
retentionMode?: string;
|
|
233
|
+
} | null {
|
|
234
|
+
try {
|
|
235
|
+
const c = (
|
|
236
|
+
globalThis as {
|
|
237
|
+
__monitorConfig?: {
|
|
238
|
+
title: string;
|
|
239
|
+
subtitle: string;
|
|
240
|
+
refreshMs: number;
|
|
241
|
+
path: string;
|
|
242
|
+
retentionDays?: number;
|
|
243
|
+
retentionMode?: string;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
).__monitorConfig;
|
|
247
|
+
return c ?? null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async _load(): Promise<void> {
|
|
254
|
+
this.snap = await this._store().snapshot(this.range);
|
|
255
|
+
// Keep the open route-detail view in sync with the snapshot/range.
|
|
256
|
+
if (this.tab === "route" && this.routeMethod) await this._loadRoute();
|
|
257
|
+
await this._loadSection();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve the active contributed section for the current range.
|
|
262
|
+
*
|
|
263
|
+
* A section that throws is shown as empty rather than taking the panel down —
|
|
264
|
+
* the monitor has to stay up precisely when the thing it watches is unhealthy.
|
|
265
|
+
*/
|
|
266
|
+
private async _loadSection(): Promise<void> {
|
|
267
|
+
const section = MonitorPanel.find(this.tab);
|
|
268
|
+
if (!section) {
|
|
269
|
+
this.sectionData = null;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
this.sectionData = await section.resolve(this.range);
|
|
274
|
+
} catch {
|
|
275
|
+
this.sectionData = { stats: [], tables: [] };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async _loadRoute(): Promise<void> {
|
|
280
|
+
this.routeDetail = await this._store().routeDetail(
|
|
281
|
+
this.routeMethod,
|
|
282
|
+
this.routePath,
|
|
283
|
+
this.range,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Actions ───────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
@expose setTab(id: string): void {
|
|
290
|
+
this.tab = id;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Drill into a single route's latency/error/throughput history. */
|
|
294
|
+
@expose async openRoute(method: string, path: string): Promise<void> {
|
|
295
|
+
this.routeMethod = method;
|
|
296
|
+
this.routePath = path;
|
|
297
|
+
this.tab = "route";
|
|
298
|
+
this.routeDetail = null;
|
|
299
|
+
await this._loadRoute();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Leave the route drill-in and return to the requests list. */
|
|
303
|
+
@expose backFromRoute(): void {
|
|
304
|
+
this.tab = "requests";
|
|
305
|
+
this.routeMethod = "";
|
|
306
|
+
this.routePath = "";
|
|
307
|
+
this.routeDetail = null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@expose async setRange(r: MonitorRange): Promise<void> {
|
|
311
|
+
this.range = r;
|
|
312
|
+
this.live = r === "live";
|
|
313
|
+
this.reqPage = 0;
|
|
314
|
+
await this._load();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@expose async toggleLive(): Promise<void> {
|
|
318
|
+
this.live = !this.live;
|
|
319
|
+
if (!this.live && this.range === "live") this.range = "1h";
|
|
320
|
+
if (this.live) this.range = "live";
|
|
321
|
+
await this._load();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@expose async refreshData(): Promise<void> {
|
|
325
|
+
await this._load();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
@expose setStatusFilter(f: string): void {
|
|
329
|
+
this.statusFilter = f;
|
|
330
|
+
this.reqPage = 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Requests-table pagination (10 per page, over the loaded recent set). */
|
|
334
|
+
@expose prevReqPage(): void {
|
|
335
|
+
if (this.reqPage > 0) this.reqPage--;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
@expose nextReqPage(): void {
|
|
339
|
+
const maxPage = Math.max(0, Math.ceil(this.filteredRequests().length / REQ_PER_PAGE) - 1);
|
|
340
|
+
if (this.reqPage < maxPage) this.reqPage++;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
@expose setTag(t: string): void {
|
|
344
|
+
this.activeTag = t;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
@expose dismissAlert(i: number): void {
|
|
348
|
+
if (!this.dismissed.includes(i)) this.dismissed = [...this.dismissed, i];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
@expose openReq(id: number): void {
|
|
352
|
+
this.openRequestId = this.openRequestId === id ? 0 : id;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@expose toggleExc(type: string): void {
|
|
356
|
+
this.openExc = this.openExc === type ? "" : type;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
@expose toggleAlert(id: string): void {
|
|
360
|
+
this.openAlert = this.openAlert === id ? "" : id;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@expose toggleMail(id: number): void {
|
|
364
|
+
this.openMailId = this.openMailId === id ? 0 : id;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
@expose toggleWsAction(id: number): void {
|
|
368
|
+
this.openWsAction = this.openWsAction === id ? 0 : id;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@expose setLogLevel(level: string): void {
|
|
372
|
+
this.logLevel = level;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Secondary-feed search / filter / pagination ─────────────────────────────
|
|
376
|
+
|
|
377
|
+
@expose setFeedStatus(s: string): void {
|
|
378
|
+
this.feedStatus = s;
|
|
379
|
+
this.feedPage = 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
@expose prevFeedPage(): void {
|
|
383
|
+
if (this.feedPage > 0) this.feedPage--;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@expose nextFeedPage(): void {
|
|
387
|
+
const maxPage = Math.max(0, Math.ceil(this._activeFeedTotal() / FEED_PER_PAGE) - 1);
|
|
388
|
+
if (this.feedPage < maxPage) this.feedPage++;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private _matchesStatus(status: "ok" | "bad"): boolean {
|
|
392
|
+
return (
|
|
393
|
+
this.feedStatus === "all" ||
|
|
394
|
+
(this.feedStatus === "ok" && status === "ok") ||
|
|
395
|
+
(this.feedStatus === "failed" && status === "bad")
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private _filteredNotifications() {
|
|
400
|
+
const ql = this.feedQ.trim().toLowerCase();
|
|
401
|
+
return this.snap.notifications.filter(
|
|
402
|
+
(n) =>
|
|
403
|
+
this._matchesStatus(n.status) &&
|
|
404
|
+
(ql === "" || `${n.notification} ${n.recipient} ${n.channel}`.toLowerCase().includes(ql)),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private _filteredCommands() {
|
|
409
|
+
const ql = this.feedQ.trim().toLowerCase();
|
|
410
|
+
return this.snap.commands.filter(
|
|
411
|
+
(c) => this._matchesStatus(c.status) && (ql === "" || c.name.toLowerCase().includes(ql)),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private _filteredSecurity() {
|
|
416
|
+
const ql = this.feedQ.trim().toLowerCase();
|
|
417
|
+
if (ql === "") return this.snap.security;
|
|
418
|
+
return this.snap.security.filter((e) =>
|
|
419
|
+
`${e.label} ${e.detail} ${e.route ?? ""}`.toLowerCase().includes(ql),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private _filteredWsActions() {
|
|
424
|
+
const ql = this.feedQ.trim().toLowerCase();
|
|
425
|
+
return this.snap.realtime.recentActions.filter(
|
|
426
|
+
(a) =>
|
|
427
|
+
this._matchesStatus(a.ok ? "ok" : "bad") &&
|
|
428
|
+
(ql === "" ||
|
|
429
|
+
`${a.component} ${a.action} ${a.user ?? ""} ${a.ip ?? ""}`.toLowerCase().includes(ql)),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private _activeFeedTotal(): number {
|
|
434
|
+
if (this.tab === "notifications") return this._filteredNotifications().length;
|
|
435
|
+
if (this.tab === "commands") return this._filteredCommands().length;
|
|
436
|
+
if (this.tab === "security") return this._filteredSecurity().length;
|
|
437
|
+
if (this.tab === "realtime") return this._filteredWsActions().length;
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Pagination math for the active secondary feed. */
|
|
442
|
+
private _feedSlice(total: number): {
|
|
443
|
+
page: number;
|
|
444
|
+
totalPages: number;
|
|
445
|
+
start: number;
|
|
446
|
+
end: number;
|
|
447
|
+
} {
|
|
448
|
+
const totalPages = Math.max(1, Math.ceil(total / FEED_PER_PAGE));
|
|
449
|
+
const page = Math.min(Math.max(0, this.feedPage), totalPages - 1);
|
|
450
|
+
const start = page * FEED_PER_PAGE;
|
|
451
|
+
return { page, totalPages, start, end: start + FEED_PER_PAGE };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
@expose async pauseQueue(name: string): Promise<void> {
|
|
455
|
+
const paused = this._store().toggleQueuePaused(name);
|
|
456
|
+
await this._load();
|
|
457
|
+
this.flash(`Queue "${name}" ${paused ? "paused" : "resumed"}.`, paused ? "warning" : "success");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
@expose async retryJob(id: number): Promise<void> {
|
|
461
|
+
await this._store().retryFailed(id);
|
|
462
|
+
await this._load();
|
|
463
|
+
this.flash("Job re-queued for retry.", "success");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
@expose async requeueDead(id: number): Promise<void> {
|
|
467
|
+
await this._store().requeueDead(id);
|
|
468
|
+
await this._load();
|
|
469
|
+
this.flash("Dead-letter job requeued.", "success");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Prune data past the retention window now (delete or archive per config). */
|
|
473
|
+
@expose async cleanupData(): Promise<void> {
|
|
474
|
+
const removed = this._store().prune();
|
|
475
|
+
await this._load();
|
|
476
|
+
this.flash(
|
|
477
|
+
removed > 0
|
|
478
|
+
? `Pruned ${removed} record${removed === 1 ? "" : "s"} past retention.`
|
|
479
|
+
: "Nothing to prune — all data is within retention.",
|
|
480
|
+
"success",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Permanently delete every recorded sample. */
|
|
485
|
+
@expose async clearData(): Promise<void> {
|
|
486
|
+
const removed = this._store().wipe();
|
|
487
|
+
await this._load();
|
|
488
|
+
this.flash(`Cleared ${removed} record${removed === 1 ? "" : "s"}.`, "warning");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Derived ─────────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
private badge(kind: "exceptions" | "queues" | "alerts"): number {
|
|
494
|
+
if (!this.snap) return 0;
|
|
495
|
+
if (kind === "exceptions") return this.snap.exceptions.reduce((a, e) => a + e.count, 0);
|
|
496
|
+
if (kind === "alerts") return this.snap.alertHistory.length;
|
|
497
|
+
return this.snap.queues.reduce((a, q) => a + q.pending, 0);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private filteredRequests(): RequestEntry[] {
|
|
501
|
+
const ql = this.q.trim().toLowerCase();
|
|
502
|
+
return this.snap.requests.filter(
|
|
503
|
+
(r) =>
|
|
504
|
+
(this.statusFilter === "all" ||
|
|
505
|
+
(this.statusFilter === "ok" && r.status < 400) ||
|
|
506
|
+
(this.statusFilter === "err" && r.status >= 400)) &&
|
|
507
|
+
(ql === "" || `${r.path} ${r.method}`.toLowerCase().includes(ql)),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private filteredFailed() {
|
|
512
|
+
return this.activeTag === "all"
|
|
513
|
+
? this.snap.failedJobs
|
|
514
|
+
: this.snap.failedJobs.filter((j) => j.tags.includes(this.activeTag));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private activeLabel(): string {
|
|
518
|
+
if (this.tab === "route") return "Route detail";
|
|
519
|
+
for (const g of NAV) for (const it of g.items) if (it.id === this.tab) return it.label;
|
|
520
|
+
return MonitorPanel.find(this.tab)?.label ?? "Overview";
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
override async render(): Promise<HtmlNode> {
|
|
526
|
+
if (!this.snap) {
|
|
527
|
+
return <div class="grid h-screen place-items-center text-muted-foreground">Loading…</div>;
|
|
528
|
+
}
|
|
529
|
+
return (
|
|
530
|
+
<div class="flex h-full">
|
|
531
|
+
{this.renderSidebar()}
|
|
532
|
+
<div class="flex-1 flex flex-col min-w-0">
|
|
533
|
+
{this.renderTopbar()}
|
|
534
|
+
<main class="flex-1 overflow-y-auto p-5 sm:p-7">
|
|
535
|
+
{this.live ? (
|
|
536
|
+
<div
|
|
537
|
+
poll={{ every: `${Math.round(this.refreshMs / 1000)}s`, action: this.refreshData }}
|
|
538
|
+
class="hidden"
|
|
539
|
+
/>
|
|
540
|
+
) : (
|
|
541
|
+
""
|
|
542
|
+
)}
|
|
543
|
+
{this.renderAlerts()}
|
|
544
|
+
{this.tab === "overview" ? this.renderOverview() : ""}
|
|
545
|
+
{this.tab === "requests" ? this.renderRequests() : ""}
|
|
546
|
+
{this.tab === "route" ? this.renderRouteDetail() : ""}
|
|
547
|
+
{this.tab === "realtime" ? this.renderRealtime() : ""}
|
|
548
|
+
{this.tab === "exceptions" ? this.renderExceptions() : ""}
|
|
549
|
+
{this.tab === "alerts" ? this.renderAlertsTab() : ""}
|
|
550
|
+
{this.tab === "security" ? this.renderSecurity() : ""}
|
|
551
|
+
{this.tab === "logs" ? this.renderLogs() : ""}
|
|
552
|
+
{this.tab === "queues" ? this.renderQueues() : ""}
|
|
553
|
+
{this.tab === "mail" ? this.renderMail() : ""}
|
|
554
|
+
{this.tab === "notifications" ? this.renderNotifications() : ""}
|
|
555
|
+
{this.tab === "database" ? this.renderDatabase() : ""}
|
|
556
|
+
{this.tab === "cache" ? this.renderCache() : ""}
|
|
557
|
+
{this.tab === "commands" ? this.renderCommands() : ""}
|
|
558
|
+
{this.tab === "system" ? this.renderSystem() : ""}
|
|
559
|
+
{MonitorPanel.find(this.tab) ? this.renderContributedSection() : ""}
|
|
560
|
+
</main>
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── Contributed sections ────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Draw a section another package described.
|
|
570
|
+
*
|
|
571
|
+
* The contributor supplies stats and tables; the markup is the panel's, so a
|
|
572
|
+
* contributed section is indistinguishable from a built-in one and a package
|
|
573
|
+
* needs no JSX to add one.
|
|
574
|
+
*/
|
|
575
|
+
private renderContributedSection(): HtmlNode {
|
|
576
|
+
const data = this.sectionData;
|
|
577
|
+
const stats = data?.stats ?? [];
|
|
578
|
+
const tables = data?.tables ?? [];
|
|
579
|
+
|
|
580
|
+
if (stats.length === 0 && tables.length === 0) {
|
|
581
|
+
return (
|
|
582
|
+
<Card class="p-12 text-center">
|
|
583
|
+
<div class="text-sm font-medium text-muted-foreground">Nothing recorded yet</div>
|
|
584
|
+
<div class="mt-1 text-xs text-muted-foreground">
|
|
585
|
+
This section has no data for the selected range.
|
|
586
|
+
</div>
|
|
587
|
+
</Card>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<section class="space-y-6">
|
|
593
|
+
{stats.length > 0 ? (
|
|
594
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
595
|
+
{stats.map((s) => this.renderSectionStat(s))}
|
|
596
|
+
</div>
|
|
597
|
+
) : (
|
|
598
|
+
""
|
|
599
|
+
)}
|
|
600
|
+
{tables.map((t) => this.renderSectionTable(t))}
|
|
601
|
+
</section>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private renderSectionStat(s: MonitorStat): HtmlNode {
|
|
606
|
+
return (
|
|
607
|
+
<Card key={s.label} class="p-5">
|
|
608
|
+
<div class="text-xs font-medium text-muted-foreground">{s.label}</div>
|
|
609
|
+
<div class={["mt-2 text-3xl font-bold tabular", SECTION_TONE[s.tone ?? "default"]]}>
|
|
610
|
+
{String(s.value)}
|
|
611
|
+
</div>
|
|
612
|
+
{s.percent !== undefined ? (
|
|
613
|
+
<div class="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
|
614
|
+
<div
|
|
615
|
+
class={["h-full transition-all", SECTION_BAR[s.tone ?? "default"]]}
|
|
616
|
+
style={`width:${Math.max(0, Math.min(100, s.percent))}%`}
|
|
617
|
+
/>
|
|
618
|
+
</div>
|
|
619
|
+
) : (
|
|
620
|
+
""
|
|
621
|
+
)}
|
|
622
|
+
{s.detail ? (
|
|
623
|
+
<div class="mt-2 text-[11px] text-muted-foreground tabular">{s.detail}</div>
|
|
624
|
+
) : (
|
|
625
|
+
""
|
|
626
|
+
)}
|
|
627
|
+
</Card>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private renderSectionTable(t: MonitorTable): HtmlNode {
|
|
632
|
+
// Rendered with flow-ui's Table so a contributed section gets the same
|
|
633
|
+
// sorting affordances, row keys and hover behaviour as every other table
|
|
634
|
+
// Zerotal ships — the contributor describes columns, the kit draws them.
|
|
635
|
+
const columns: TableColumn[] = t.columns.map((c) => ({
|
|
636
|
+
key: c.key,
|
|
637
|
+
label: c.label,
|
|
638
|
+
class: [
|
|
639
|
+
c.align === "end" ? "text-right tabular" : "text-left",
|
|
640
|
+
c.mono ? "font-mono text-xs" : "",
|
|
641
|
+
]
|
|
642
|
+
.filter(Boolean)
|
|
643
|
+
.join(" "),
|
|
644
|
+
render: (row: MonitorRow) => {
|
|
645
|
+
const raw = row[c.key];
|
|
646
|
+
const text = c.format
|
|
647
|
+
? c.format(raw, row)
|
|
648
|
+
: raw === null || raw === undefined
|
|
649
|
+
? "—"
|
|
650
|
+
: String(raw);
|
|
651
|
+
const tone = c.tone?.(raw, row) ?? null;
|
|
652
|
+
return tone ? <span class={SECTION_TONE[tone]}>{text}</span> : text;
|
|
653
|
+
},
|
|
654
|
+
}));
|
|
655
|
+
|
|
656
|
+
return (
|
|
657
|
+
<Card key={t.title} class="overflow-hidden">
|
|
658
|
+
<CardHeader class="border-b p-0">
|
|
659
|
+
<CardTitle class="px-5 py-3 text-sm font-semibold">{t.title}</CardTitle>
|
|
660
|
+
</CardHeader>
|
|
661
|
+
{t.rows.length === 0 ? (
|
|
662
|
+
<div class="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
663
|
+
{t.empty ?? "Nothing to show."}
|
|
664
|
+
</div>
|
|
665
|
+
) : (
|
|
666
|
+
<div class="overflow-x-auto">
|
|
667
|
+
<Table columns={columns} rows={t.rows} hover />
|
|
668
|
+
</div>
|
|
669
|
+
)}
|
|
670
|
+
</Card>
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── Sidebar ─────────────────────────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
private renderSidebar(): HtmlNode {
|
|
677
|
+
return (
|
|
678
|
+
<aside class="hidden md:flex w-60 shrink-0 flex-col border-r border-border bg-card">
|
|
679
|
+
<div class="flex items-center gap-2.5 px-5 h-16 border-b border-border">
|
|
680
|
+
<div
|
|
681
|
+
class="grid place-items-center w-9 h-9 rounded-[10px] text-primary-foreground shrink-0"
|
|
682
|
+
style="background:linear-gradient(135deg,hsl(var(--primary)),hsl(var(--primary)/0.75));box-shadow:0 2px 8px hsl(var(--primary)/0.35)"
|
|
683
|
+
>
|
|
684
|
+
<span
|
|
685
|
+
class="w-[18px] h-[18px] block"
|
|
686
|
+
dangerouslySetInnerHTML={{ __html: icons.logo! }}
|
|
687
|
+
/>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="leading-tight">
|
|
690
|
+
<div class="font-extrabold text-foreground text-[15px] tracking-tight">
|
|
691
|
+
{this.brandTitle}
|
|
692
|
+
</div>
|
|
693
|
+
<div class="font-mono text-[10px] text-primary/80">v1.1 · {this.brandSubtitle}</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<nav class="flex-1 overflow-y-auto p-3 space-y-4">
|
|
698
|
+
{this._navGroups().map((grp) => (
|
|
699
|
+
<div key={grp.label}>
|
|
700
|
+
<div class="px-3 mb-1 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
|
701
|
+
{grp.label}
|
|
702
|
+
</div>
|
|
703
|
+
<div class="space-y-0.5">
|
|
704
|
+
{grp.items.map((item) => {
|
|
705
|
+
const active = this.tab === item.id;
|
|
706
|
+
const badgeCount = item.badge ? this.badge(item.badge) : 0;
|
|
707
|
+
return (
|
|
708
|
+
<a
|
|
709
|
+
key={item.id}
|
|
710
|
+
navigate
|
|
711
|
+
href={this._sectionHref(item.id)}
|
|
712
|
+
class={[
|
|
713
|
+
"group w-full flex items-center gap-3 px-3 py-2 rounded-lg text-[13.5px] font-medium transition-colors",
|
|
714
|
+
active
|
|
715
|
+
? "bg-primary/10 text-primary font-semibold"
|
|
716
|
+
: "text-muted-foreground hover:bg-accent",
|
|
717
|
+
]}
|
|
718
|
+
>
|
|
719
|
+
<span
|
|
720
|
+
class={[
|
|
721
|
+
"w-5 h-5 shrink-0 block",
|
|
722
|
+
active
|
|
723
|
+
? "text-primary"
|
|
724
|
+
: "text-muted-foreground group-hover:text-foreground",
|
|
725
|
+
]}
|
|
726
|
+
dangerouslySetInnerHTML={{ __html: item.icon }}
|
|
727
|
+
/>
|
|
728
|
+
<span>{item.label}</span>
|
|
729
|
+
{item.badge && badgeCount ? (
|
|
730
|
+
<span
|
|
731
|
+
class={[
|
|
732
|
+
"ml-auto text-[10px] font-bold px-1.5 py-0.5 rounded-full",
|
|
733
|
+
item.badgeTone === "red"
|
|
734
|
+
? "bg-destructive/10 text-destructive"
|
|
735
|
+
: "bg-muted text-muted-foreground",
|
|
736
|
+
]}
|
|
737
|
+
>
|
|
738
|
+
{badgeCount}
|
|
739
|
+
</span>
|
|
740
|
+
) : (
|
|
741
|
+
<span
|
|
742
|
+
class={[
|
|
743
|
+
"ml-auto w-1.5 h-1.5 rounded-full transition-colors",
|
|
744
|
+
active ? "bg-primary" : "bg-muted",
|
|
745
|
+
]}
|
|
746
|
+
/>
|
|
747
|
+
)}
|
|
748
|
+
</a>
|
|
749
|
+
);
|
|
750
|
+
})}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
))}
|
|
754
|
+
</nav>
|
|
755
|
+
</aside>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Topbar ────────────────────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
private renderTopbar(): HtmlNode {
|
|
762
|
+
const env = this.snap.meta.environment;
|
|
763
|
+
const isProd = /^prod/i.test(env);
|
|
764
|
+
const allOk = this.snap.health.every((h) => h.ok);
|
|
765
|
+
return (
|
|
766
|
+
<header class="h-16 shrink-0 flex items-center gap-4 px-5 sm:px-7 border-b border-border bg-card/80 backdrop-blur">
|
|
767
|
+
<h1 class="text-lg font-bold text-foreground tracking-tight">{this.activeLabel()}</h1>
|
|
768
|
+
<span
|
|
769
|
+
class={[
|
|
770
|
+
"hidden sm:inline-flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wide px-2 py-1 rounded-full border",
|
|
771
|
+
isProd
|
|
772
|
+
? "bg-success/10 text-success border-success/30"
|
|
773
|
+
: "bg-warning/10 text-warning border-warning/30",
|
|
774
|
+
]}
|
|
775
|
+
>
|
|
776
|
+
<span class={["w-1.5 h-1.5 rounded-full", isProd ? "bg-success" : "bg-warning"]} /> {env}
|
|
777
|
+
</span>
|
|
778
|
+
<span
|
|
779
|
+
class={[
|
|
780
|
+
"hidden md:inline-flex items-center gap-1.5 text-xs font-medium",
|
|
781
|
+
allOk ? "text-success" : "text-destructive",
|
|
782
|
+
]}
|
|
783
|
+
>
|
|
784
|
+
<span class="relative flex h-2 w-2">
|
|
785
|
+
<span
|
|
786
|
+
class={[
|
|
787
|
+
"absolute inline-flex h-full w-full rounded-full opacity-75",
|
|
788
|
+
allOk ? "bg-success/80 animate-ping" : "bg-destructive/80",
|
|
789
|
+
]}
|
|
790
|
+
/>
|
|
791
|
+
<span
|
|
792
|
+
class={[
|
|
793
|
+
"relative inline-flex rounded-full h-2 w-2",
|
|
794
|
+
allOk ? "bg-success" : "bg-destructive",
|
|
795
|
+
]}
|
|
796
|
+
/>
|
|
797
|
+
</span>
|
|
798
|
+
{allOk ? "All systems operational" : "Degraded"}
|
|
799
|
+
</span>
|
|
800
|
+
|
|
801
|
+
<div class="ml-auto flex items-center gap-3">
|
|
802
|
+
<span class="text-xs text-muted-foreground hidden lg:inline">
|
|
803
|
+
{this.live ? `auto-refresh ${Math.round(this.refreshMs / 1000)}s` : "paused"}
|
|
804
|
+
</span>
|
|
805
|
+
|
|
806
|
+
<div class="hidden sm:flex items-center p-0.5 rounded-lg bg-muted border border-border">
|
|
807
|
+
{RANGES.map((r) => (
|
|
808
|
+
<button
|
|
809
|
+
key={r}
|
|
810
|
+
onClick={() => this.setRange(r)}
|
|
811
|
+
class={[
|
|
812
|
+
"px-2.5 py-1 rounded-md text-xs font-semibold transition-colors capitalize",
|
|
813
|
+
this.range === r
|
|
814
|
+
? "bg-card text-foreground shadow-sm"
|
|
815
|
+
: "text-muted-foreground hover:text-foreground",
|
|
816
|
+
]}
|
|
817
|
+
>
|
|
818
|
+
{r}
|
|
819
|
+
</button>
|
|
820
|
+
))}
|
|
821
|
+
</div>
|
|
822
|
+
|
|
823
|
+
<button
|
|
824
|
+
onClick={this.toggleLive}
|
|
825
|
+
class={[
|
|
826
|
+
"inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors",
|
|
827
|
+
this.live
|
|
828
|
+
? "bg-primary/10 border-primary/30 text-primary"
|
|
829
|
+
: "bg-card border-border text-muted-foreground",
|
|
830
|
+
]}
|
|
831
|
+
>
|
|
832
|
+
<span class="relative flex h-2 w-2">
|
|
833
|
+
<span
|
|
834
|
+
class={[
|
|
835
|
+
"absolute inline-flex h-full w-full rounded-full bg-primary/80 opacity-75",
|
|
836
|
+
this.live ? "animate-ping" : "",
|
|
837
|
+
]}
|
|
838
|
+
/>
|
|
839
|
+
<span
|
|
840
|
+
class={[
|
|
841
|
+
"relative inline-flex rounded-full h-2 w-2",
|
|
842
|
+
this.live ? "bg-primary" : "bg-muted-foreground/40",
|
|
843
|
+
]}
|
|
844
|
+
/>
|
|
845
|
+
</span>
|
|
846
|
+
<span>{this.live ? "Live" : "Paused"}</span>
|
|
847
|
+
</button>
|
|
848
|
+
|
|
849
|
+
<button
|
|
850
|
+
onClick={this.refreshData}
|
|
851
|
+
title="Refresh"
|
|
852
|
+
class="grid place-items-center w-9 h-9 rounded-lg border border-border bg-card text-muted-foreground hover:text-primary hover:border-primary/40 transition-colors"
|
|
853
|
+
>
|
|
854
|
+
<svg
|
|
855
|
+
viewBox="0 0 20 20"
|
|
856
|
+
fill="none"
|
|
857
|
+
stroke="currentColor"
|
|
858
|
+
stroke-width="1.7"
|
|
859
|
+
class="w-4 h-4"
|
|
860
|
+
stroke-linecap="round"
|
|
861
|
+
stroke-linejoin="round"
|
|
862
|
+
>
|
|
863
|
+
<path d="M16.5 9a6.5 6.5 0 1 0-1.2 4.5" />
|
|
864
|
+
<path d="M16.5 3.5V9H11" />
|
|
865
|
+
</svg>
|
|
866
|
+
</button>
|
|
867
|
+
|
|
868
|
+
<div class="w-px h-6 bg-muted mx-1 hidden sm:block" />
|
|
869
|
+
<div
|
|
870
|
+
class="grid place-items-center w-8 h-8 rounded-full text-primary-foreground text-[11px] font-bold shrink-0"
|
|
871
|
+
style="background:linear-gradient(135deg,hsl(var(--primary)),hsl(var(--primary)/0.75))"
|
|
872
|
+
title="Operator · Admin"
|
|
873
|
+
>
|
|
874
|
+
SM
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
</header>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── Alerts banner ─────────────────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
private renderAlerts(): HtmlNode {
|
|
884
|
+
const alerts = this.snap.alerts;
|
|
885
|
+
const visible = alerts.filter((_, i) => !this.dismissed.includes(i));
|
|
886
|
+
if (visible.length === 0) return <span />;
|
|
887
|
+
return (
|
|
888
|
+
<div class="space-y-2 mb-5">
|
|
889
|
+
{alerts.map((a, i) =>
|
|
890
|
+
this.dismissed.includes(i) ? (
|
|
891
|
+
""
|
|
892
|
+
) : (
|
|
893
|
+
<div
|
|
894
|
+
key={i}
|
|
895
|
+
class={[
|
|
896
|
+
"flex items-start gap-3 px-4 py-2.5 rounded-lg border text-sm",
|
|
897
|
+
a.tone === "red"
|
|
898
|
+
? "bg-destructive/10 border-destructive/30 text-destructive"
|
|
899
|
+
: "bg-warning/10 border-warning/30 text-warning",
|
|
900
|
+
]}
|
|
901
|
+
>
|
|
902
|
+
<span
|
|
903
|
+
class={[
|
|
904
|
+
"mt-1.5 w-1.5 h-1.5 rounded-full shrink-0",
|
|
905
|
+
a.tone === "red" ? "bg-destructive" : "bg-warning",
|
|
906
|
+
]}
|
|
907
|
+
/>
|
|
908
|
+
<span class="flex-1">{a.text}</span>
|
|
909
|
+
<button
|
|
910
|
+
onClick={() => this.dismissAlert(i)}
|
|
911
|
+
class="shrink-0 opacity-60 hover:opacity-100 text-xs font-bold"
|
|
912
|
+
>
|
|
913
|
+
✕
|
|
914
|
+
</button>
|
|
915
|
+
</div>
|
|
916
|
+
),
|
|
917
|
+
)}
|
|
918
|
+
</div>
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── Overview ────────────────────────────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
/** One card in the Overview live-pulse row: a status dot, a label, a big number, a sub-line. */
|
|
925
|
+
private _pulseCard(
|
|
926
|
+
label: string,
|
|
927
|
+
value: string,
|
|
928
|
+
sub: string,
|
|
929
|
+
pulsing: boolean,
|
|
930
|
+
danger = false,
|
|
931
|
+
): HtmlNode {
|
|
932
|
+
return (
|
|
933
|
+
<Card class="p-4">
|
|
934
|
+
<div class="flex items-center gap-2">
|
|
935
|
+
<span class="relative flex h-2 w-2">
|
|
936
|
+
{pulsing ? (
|
|
937
|
+
<span
|
|
938
|
+
class={[
|
|
939
|
+
"absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping",
|
|
940
|
+
danger ? "bg-destructive/80" : "bg-success/80",
|
|
941
|
+
]}
|
|
942
|
+
/>
|
|
943
|
+
) : (
|
|
944
|
+
""
|
|
945
|
+
)}
|
|
946
|
+
<span
|
|
947
|
+
class={[
|
|
948
|
+
"relative inline-flex rounded-full h-2 w-2",
|
|
949
|
+
danger ? "bg-destructive" : pulsing ? "bg-success" : "bg-muted-foreground/40",
|
|
950
|
+
]}
|
|
951
|
+
/>
|
|
952
|
+
</span>
|
|
953
|
+
<span class="text-xs font-medium text-muted-foreground">{label}</span>
|
|
954
|
+
</div>
|
|
955
|
+
<div
|
|
956
|
+
class={[
|
|
957
|
+
"mt-1.5 text-3xl font-bold tabular font-mono",
|
|
958
|
+
danger ? "text-destructive" : "text-foreground",
|
|
959
|
+
]}
|
|
960
|
+
>
|
|
961
|
+
{value}
|
|
962
|
+
</div>
|
|
963
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">{sub}</div>
|
|
964
|
+
</Card>
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private renderOverview(): HtmlNode {
|
|
969
|
+
const s = this.snap;
|
|
970
|
+
const p = s.pulse;
|
|
971
|
+
const pct = (label: string): number => s.percentiles.find((x) => x.label === label)?.value ?? 0;
|
|
972
|
+
const p99 = Math.max(1, pct("p99"));
|
|
973
|
+
const tp = areaPaths(s.throughput, 720, 180);
|
|
974
|
+
return (
|
|
975
|
+
<section class="space-y-6">
|
|
976
|
+
{/* Row 1 · Live pulse — what's happening right now */}
|
|
977
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
978
|
+
{this._pulseCard(
|
|
979
|
+
"Active requests",
|
|
980
|
+
commas(p.activeRequests),
|
|
981
|
+
"processing now",
|
|
982
|
+
p.activeRequests > 0,
|
|
983
|
+
)}
|
|
984
|
+
{this._pulseCard(
|
|
985
|
+
"WS connections",
|
|
986
|
+
commas(p.activeConnections),
|
|
987
|
+
"open sockets",
|
|
988
|
+
p.activeConnections > 0,
|
|
989
|
+
)}
|
|
990
|
+
{this._pulseCard(
|
|
991
|
+
"Requests / sec",
|
|
992
|
+
String(p.requestsPerSec),
|
|
993
|
+
"last 5 min",
|
|
994
|
+
p.requestsPerSec > 0,
|
|
995
|
+
)}
|
|
996
|
+
{this._pulseCard(
|
|
997
|
+
"Error rate",
|
|
998
|
+
`${p.errorRatePct}%`,
|
|
999
|
+
"last 5 min",
|
|
1000
|
+
false,
|
|
1001
|
+
p.errorRatePct > 1,
|
|
1002
|
+
)}
|
|
1003
|
+
</div>
|
|
1004
|
+
|
|
1005
|
+
{/* Row 2 · Performance & health */}
|
|
1006
|
+
<div class="grid lg:grid-cols-3 gap-4">
|
|
1007
|
+
<Card class="p-5">
|
|
1008
|
+
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
|
1009
|
+
Latency profile
|
|
1010
|
+
</div>
|
|
1011
|
+
<div class="space-y-2.5">
|
|
1012
|
+
{(["p50", "p95", "p99"] as const).map((label) => {
|
|
1013
|
+
const v = pct(label);
|
|
1014
|
+
return (
|
|
1015
|
+
<div key={label} class="flex items-center gap-3">
|
|
1016
|
+
<span class="text-[11px] font-mono text-muted-foreground w-8 shrink-0">
|
|
1017
|
+
{label}
|
|
1018
|
+
</span>
|
|
1019
|
+
<div class="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
1020
|
+
<div
|
|
1021
|
+
class={[
|
|
1022
|
+
"h-full rounded-full",
|
|
1023
|
+
v > 500 ? "bg-destructive" : v > 200 ? "bg-warning" : "bg-primary",
|
|
1024
|
+
]}
|
|
1025
|
+
style={`width:${Math.min(100, Math.round((v / p99) * 100))}%`}
|
|
1026
|
+
/>
|
|
1027
|
+
</div>
|
|
1028
|
+
<span
|
|
1029
|
+
class={["tabular font-semibold text-sm w-14 text-right shrink-0", pctTone(v)]}
|
|
1030
|
+
>
|
|
1031
|
+
{v}ms
|
|
1032
|
+
</span>
|
|
1033
|
+
</div>
|
|
1034
|
+
);
|
|
1035
|
+
})}
|
|
1036
|
+
</div>
|
|
1037
|
+
</Card>
|
|
1038
|
+
|
|
1039
|
+
<Card class="p-5">
|
|
1040
|
+
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
|
1041
|
+
System health
|
|
1042
|
+
</div>
|
|
1043
|
+
<div class="grid grid-cols-2 gap-4">
|
|
1044
|
+
<div>
|
|
1045
|
+
<div
|
|
1046
|
+
class={[
|
|
1047
|
+
"text-2xl font-bold tabular font-mono",
|
|
1048
|
+
s.apdex >= 0.94
|
|
1049
|
+
? "text-success"
|
|
1050
|
+
: s.apdex >= 0.85
|
|
1051
|
+
? "text-warning"
|
|
1052
|
+
: "text-destructive",
|
|
1053
|
+
]}
|
|
1054
|
+
>
|
|
1055
|
+
{s.apdex.toFixed(2)}
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">Apdex score</div>
|
|
1058
|
+
</div>
|
|
1059
|
+
<div>
|
|
1060
|
+
<div
|
|
1061
|
+
class={[
|
|
1062
|
+
"text-2xl font-bold tabular font-mono",
|
|
1063
|
+
s.cache.hitRate >= 90
|
|
1064
|
+
? "text-success"
|
|
1065
|
+
: s.cache.hitRate >= 70
|
|
1066
|
+
? "text-warning"
|
|
1067
|
+
: "text-muted-foreground",
|
|
1068
|
+
]}
|
|
1069
|
+
>
|
|
1070
|
+
{s.cache.hitRate}%
|
|
1071
|
+
</div>
|
|
1072
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">Cache hit rate</div>
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
</Card>
|
|
1076
|
+
|
|
1077
|
+
<Card class="p-5">
|
|
1078
|
+
<div class="flex items-center justify-between mb-3">
|
|
1079
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
1080
|
+
Busiest components
|
|
1081
|
+
</span>
|
|
1082
|
+
<a
|
|
1083
|
+
navigate
|
|
1084
|
+
href={this._sectionHref("realtime")}
|
|
1085
|
+
class="text-[11px] font-semibold text-primary hover:text-primary"
|
|
1086
|
+
>
|
|
1087
|
+
Realtime →
|
|
1088
|
+
</a>
|
|
1089
|
+
</div>
|
|
1090
|
+
<div class="space-y-1.5">
|
|
1091
|
+
{s.realtime.components.length === 0 ? (
|
|
1092
|
+
<div class="text-xs text-muted-foreground py-2">no actions yet</div>
|
|
1093
|
+
) : (
|
|
1094
|
+
s.realtime.components.slice(0, 3).map((c) => (
|
|
1095
|
+
<div key={c.name} class="flex items-center gap-2 text-sm">
|
|
1096
|
+
<span class="font-mono text-xs text-muted-foreground truncate flex-1">
|
|
1097
|
+
{c.name}
|
|
1098
|
+
</span>
|
|
1099
|
+
<span class="tabular text-[11px] text-muted-foreground shrink-0">
|
|
1100
|
+
{c.avgMs}ms
|
|
1101
|
+
</span>
|
|
1102
|
+
<span class="tabular text-xs font-semibold text-foreground w-8 text-right shrink-0">
|
|
1103
|
+
{commas(c.actions)}
|
|
1104
|
+
</span>
|
|
1105
|
+
</div>
|
|
1106
|
+
))
|
|
1107
|
+
)}
|
|
1108
|
+
</div>
|
|
1109
|
+
</Card>
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
<Card class="p-5">
|
|
1113
|
+
<div class="flex items-center justify-between mb-3">
|
|
1114
|
+
<div>
|
|
1115
|
+
<h3 class="font-semibold text-foreground">Throughput</h3>
|
|
1116
|
+
<p class="text-xs text-muted-foreground">
|
|
1117
|
+
HTTP requests + WebSocket actions · {this.range}
|
|
1118
|
+
</p>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div class="flex items-center gap-4 text-xs">
|
|
1121
|
+
<span class="flex items-center gap-1.5">
|
|
1122
|
+
<span class="w-2.5 h-2.5 rounded-sm bg-primary" />
|
|
1123
|
+
HTTP requests
|
|
1124
|
+
</span>
|
|
1125
|
+
<span class="flex items-center gap-1.5">
|
|
1126
|
+
<span class="w-2.5 h-2.5 rounded-sm bg-primary" />
|
|
1127
|
+
WebSocket actions
|
|
1128
|
+
</span>
|
|
1129
|
+
<span class="flex items-center gap-1.5">
|
|
1130
|
+
<span class="w-2.5 h-2.5 rounded-sm bg-muted-foreground/40" />
|
|
1131
|
+
jobs
|
|
1132
|
+
</span>
|
|
1133
|
+
<span class="flex items-center gap-1.5">
|
|
1134
|
+
<span class="inline-block w-0 h-3 border-l border-dashed border-foreground" />
|
|
1135
|
+
deploy
|
|
1136
|
+
</span>
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
<svg class="w-full h-44" viewBox="0 0 720 180" preserveAspectRatio="none">
|
|
1140
|
+
<defs>
|
|
1141
|
+
<linearGradient id="mon-g" x1="0" x2="0" y1="0" y2="1">
|
|
1142
|
+
<stop offset="0%" stop-color="hsl(var(--primary))" stop-opacity="0.25" />
|
|
1143
|
+
<stop offset="100%" stop-color="hsl(var(--primary))" stop-opacity="0" />
|
|
1144
|
+
</linearGradient>
|
|
1145
|
+
</defs>
|
|
1146
|
+
<line x1="0" y1="45" x2="720" y2="45" stroke="hsl(var(--border))" stroke-width="1" />
|
|
1147
|
+
<line x1="0" y1="90" x2="720" y2="90" stroke="hsl(var(--border))" stroke-width="1" />
|
|
1148
|
+
<line x1="0" y1="135" x2="720" y2="135" stroke="hsl(var(--border))" stroke-width="1" />
|
|
1149
|
+
<path d={tp.area} fill="url(#mon-g)" />
|
|
1150
|
+
<path
|
|
1151
|
+
d={tp.line}
|
|
1152
|
+
fill="none"
|
|
1153
|
+
stroke="hsl(var(--primary))"
|
|
1154
|
+
stroke-width="2.5"
|
|
1155
|
+
vector-effect="non-scaling-stroke"
|
|
1156
|
+
stroke-linejoin="round"
|
|
1157
|
+
/>
|
|
1158
|
+
<path
|
|
1159
|
+
d={areaPaths(s.jobsSeries, 720, 180).line}
|
|
1160
|
+
fill="none"
|
|
1161
|
+
stroke="hsl(var(--muted-foreground) / 0.4)"
|
|
1162
|
+
stroke-width="2"
|
|
1163
|
+
vector-effect="non-scaling-stroke"
|
|
1164
|
+
stroke-linejoin="round"
|
|
1165
|
+
/>
|
|
1166
|
+
<path
|
|
1167
|
+
d={areaPaths(s.realtime.series, 720, 180).line}
|
|
1168
|
+
fill="none"
|
|
1169
|
+
stroke="hsl(var(--chart-3))"
|
|
1170
|
+
stroke-width="2"
|
|
1171
|
+
vector-effect="non-scaling-stroke"
|
|
1172
|
+
stroke-linejoin="round"
|
|
1173
|
+
opacity="0.9"
|
|
1174
|
+
/>
|
|
1175
|
+
{s.deploys.map((d) => (
|
|
1176
|
+
<line
|
|
1177
|
+
key={d.sha}
|
|
1178
|
+
x1={((d.at / 60) * 720).toFixed(0)}
|
|
1179
|
+
x2={((d.at / 60) * 720).toFixed(0)}
|
|
1180
|
+
y1="0"
|
|
1181
|
+
y2="180"
|
|
1182
|
+
stroke="hsl(var(--foreground))"
|
|
1183
|
+
stroke-width="1.2"
|
|
1184
|
+
stroke-dasharray="3 3"
|
|
1185
|
+
opacity="0.4"
|
|
1186
|
+
vector-effect="non-scaling-stroke"
|
|
1187
|
+
/>
|
|
1188
|
+
))}
|
|
1189
|
+
</svg>
|
|
1190
|
+
<div class="flex flex-wrap gap-2 mt-3">
|
|
1191
|
+
{s.deploys.map((d) => (
|
|
1192
|
+
<span
|
|
1193
|
+
key={d.sha}
|
|
1194
|
+
class="inline-flex items-center gap-1.5 text-[11px] font-mono px-2 py-1 rounded-md bg-muted text-muted-foreground"
|
|
1195
|
+
>
|
|
1196
|
+
<span class="w-1.5 h-1.5 rounded-full bg-primary" />
|
|
1197
|
+
<span>deploy {d.sha}</span>
|
|
1198
|
+
<span class="text-muted-foreground">{d.when}</span>
|
|
1199
|
+
</span>
|
|
1200
|
+
))}
|
|
1201
|
+
</div>
|
|
1202
|
+
</Card>
|
|
1203
|
+
|
|
1204
|
+
<div>
|
|
1205
|
+
<h3 class="text-sm font-bold text-foreground mb-3">System bottlenecks</h3>
|
|
1206
|
+
<div class="grid lg:grid-cols-3 gap-6">
|
|
1207
|
+
<Card class="overflow-hidden">
|
|
1208
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm flex items-center justify-between">
|
|
1209
|
+
<span>Slowest routes</span>
|
|
1210
|
+
<span class="text-[11px] text-muted-foreground font-normal">drill in</span>
|
|
1211
|
+
</div>
|
|
1212
|
+
<div class="divide-y divide-border">
|
|
1213
|
+
{s.slowRoutes.map((r) => (
|
|
1214
|
+
<button
|
|
1215
|
+
key={r.path + r.method}
|
|
1216
|
+
onClick={() => this.openRoute(r.method, r.path)}
|
|
1217
|
+
class="w-full flex items-center gap-3 px-5 py-2.5 text-sm text-left hover:bg-accent transition-colors"
|
|
1218
|
+
>
|
|
1219
|
+
<span
|
|
1220
|
+
class={["text-[10px] font-bold px-1.5 py-0.5 rounded", methodTone(r.method)]}
|
|
1221
|
+
>
|
|
1222
|
+
{r.method}
|
|
1223
|
+
</span>
|
|
1224
|
+
<span class="font-mono text-xs text-muted-foreground truncate">{r.path}</span>
|
|
1225
|
+
<span
|
|
1226
|
+
class={[
|
|
1227
|
+
"ml-auto tabular font-semibold",
|
|
1228
|
+
r.ms > 500
|
|
1229
|
+
? "text-destructive"
|
|
1230
|
+
: r.ms > 200
|
|
1231
|
+
? "text-warning"
|
|
1232
|
+
: "text-muted-foreground",
|
|
1233
|
+
]}
|
|
1234
|
+
>
|
|
1235
|
+
{r.ms}ms
|
|
1236
|
+
</span>
|
|
1237
|
+
</button>
|
|
1238
|
+
))}
|
|
1239
|
+
</div>
|
|
1240
|
+
</Card>
|
|
1241
|
+
|
|
1242
|
+
<Card>
|
|
1243
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
1244
|
+
Top exceptions
|
|
1245
|
+
</div>
|
|
1246
|
+
<div class="divide-y divide-border">
|
|
1247
|
+
{s.exceptions.length === 0 ? (
|
|
1248
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">none 🎉</div>
|
|
1249
|
+
) : (
|
|
1250
|
+
s.exceptions.slice(0, 5).map((e) => (
|
|
1251
|
+
<div key={e.type} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
1252
|
+
<span class="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
|
1253
|
+
<span class="font-mono text-xs text-foreground truncate flex-1">
|
|
1254
|
+
{e.type}
|
|
1255
|
+
</span>
|
|
1256
|
+
{e.users > 0 ? (
|
|
1257
|
+
<span class="tabular text-[11px] text-muted-foreground shrink-0">
|
|
1258
|
+
{commas(e.users)} usr
|
|
1259
|
+
</span>
|
|
1260
|
+
) : (
|
|
1261
|
+
""
|
|
1262
|
+
)}
|
|
1263
|
+
<span class="ml-auto tabular text-xs font-bold text-destructive shrink-0">
|
|
1264
|
+
{e.count}×
|
|
1265
|
+
</span>
|
|
1266
|
+
</div>
|
|
1267
|
+
))
|
|
1268
|
+
)}
|
|
1269
|
+
</div>
|
|
1270
|
+
</Card>
|
|
1271
|
+
|
|
1272
|
+
<Card class="overflow-hidden">
|
|
1273
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm flex items-center justify-between">
|
|
1274
|
+
<span>Slow outgoing HTTP</span>
|
|
1275
|
+
<span class="text-[11px] text-muted-foreground font-normal">p95</span>
|
|
1276
|
+
</div>
|
|
1277
|
+
<div class="divide-y divide-border">
|
|
1278
|
+
{s.outgoingHttp.length === 0 ? (
|
|
1279
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
1280
|
+
no third-party calls
|
|
1281
|
+
</div>
|
|
1282
|
+
) : (
|
|
1283
|
+
s.outgoingHttp.slice(0, 5).map((h) => (
|
|
1284
|
+
<div key={h.host} class="flex items-center gap-2 px-5 py-2.5 text-sm">
|
|
1285
|
+
<span class="font-mono text-xs text-foreground truncate flex-1">
|
|
1286
|
+
{h.host}
|
|
1287
|
+
</span>
|
|
1288
|
+
<span class="tabular text-[11px] text-muted-foreground shrink-0">
|
|
1289
|
+
{h.calls.toLocaleString()}
|
|
1290
|
+
</span>
|
|
1291
|
+
<span
|
|
1292
|
+
class={[
|
|
1293
|
+
"tabular font-semibold text-xs w-14 text-right shrink-0",
|
|
1294
|
+
pctTone(h.p95),
|
|
1295
|
+
]}
|
|
1296
|
+
>
|
|
1297
|
+
{h.p95}ms
|
|
1298
|
+
</span>
|
|
1299
|
+
</div>
|
|
1300
|
+
))
|
|
1301
|
+
)}
|
|
1302
|
+
</div>
|
|
1303
|
+
</Card>
|
|
1304
|
+
</div>
|
|
1305
|
+
</div>
|
|
1306
|
+
</section>
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ── Queues ────────────────────────────────────────────────────────────────
|
|
1311
|
+
|
|
1312
|
+
private renderQueues(): HtmlNode {
|
|
1313
|
+
const s = this.snap;
|
|
1314
|
+
const failed = this.filteredFailed();
|
|
1315
|
+
return (
|
|
1316
|
+
<section class="space-y-6">
|
|
1317
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
1318
|
+
{s.queueStats.map((m) => (
|
|
1319
|
+
<div key={m.label} class="rounded-xl bg-card border border-border p-4 shadow-sm">
|
|
1320
|
+
<div class="text-xs font-medium text-muted-foreground">{m.label}</div>
|
|
1321
|
+
<div class={["mt-1.5 text-2xl font-bold tabular font-mono", toneText(m.tone)]}>
|
|
1322
|
+
{m.value}
|
|
1323
|
+
</div>
|
|
1324
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">{m.sub}</div>
|
|
1325
|
+
</div>
|
|
1326
|
+
))}
|
|
1327
|
+
</div>
|
|
1328
|
+
|
|
1329
|
+
<Card class="overflow-hidden">
|
|
1330
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
1331
|
+
Workers
|
|
1332
|
+
</div>
|
|
1333
|
+
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-muted">
|
|
1334
|
+
{s.workers.map((w) => (
|
|
1335
|
+
<div key={w.name} class="bg-card p-4">
|
|
1336
|
+
<div class="flex items-center justify-between">
|
|
1337
|
+
<span class="font-mono text-xs font-semibold text-foreground">{w.name}</span>
|
|
1338
|
+
<span
|
|
1339
|
+
class={[
|
|
1340
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded-full",
|
|
1341
|
+
w.status === "running"
|
|
1342
|
+
? "bg-success/10 text-success"
|
|
1343
|
+
: w.status === "paused"
|
|
1344
|
+
? "bg-warning/10 text-warning"
|
|
1345
|
+
: "bg-muted text-muted-foreground",
|
|
1346
|
+
]}
|
|
1347
|
+
>
|
|
1348
|
+
{w.status}
|
|
1349
|
+
</span>
|
|
1350
|
+
</div>
|
|
1351
|
+
<div class="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
|
1352
|
+
<span>
|
|
1353
|
+
<span class="font-semibold text-foreground tabular">{w.processes}</span> procs
|
|
1354
|
+
</span>
|
|
1355
|
+
<span>
|
|
1356
|
+
<span class="font-semibold text-foreground tabular">{w.jobsPerMin}</span>/min
|
|
1357
|
+
</span>
|
|
1358
|
+
</div>
|
|
1359
|
+
<Sparkline
|
|
1360
|
+
series={w.series.length ? w.series : [0, 0]}
|
|
1361
|
+
stroke="hsl(var(--muted-foreground))"
|
|
1362
|
+
class="mt-2 w-full h-6"
|
|
1363
|
+
/>
|
|
1364
|
+
</div>
|
|
1365
|
+
))}
|
|
1366
|
+
</div>
|
|
1367
|
+
</Card>
|
|
1368
|
+
|
|
1369
|
+
<Card class="overflow-hidden">
|
|
1370
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm flex items-center justify-between">
|
|
1371
|
+
<span>Queues</span>
|
|
1372
|
+
<span class="text-xs text-muted-foreground font-normal">depth · wait · throughput</span>
|
|
1373
|
+
</div>
|
|
1374
|
+
<table class="w-full text-sm">
|
|
1375
|
+
<thead class="text-left text-[11px] uppercase tracking-wide text-muted-foreground bg-muted">
|
|
1376
|
+
<tr>
|
|
1377
|
+
<th class="px-5 py-2 font-medium">Queue</th>
|
|
1378
|
+
<th class="px-3 py-2 font-medium">Pending</th>
|
|
1379
|
+
<th class="px-3 py-2 font-medium">Wait</th>
|
|
1380
|
+
<th class="px-3 py-2 font-medium">Throughput</th>
|
|
1381
|
+
<th class="px-3 py-2 font-medium">Trend</th>
|
|
1382
|
+
<th class="px-3 py-2 font-medium" />
|
|
1383
|
+
</tr>
|
|
1384
|
+
</thead>
|
|
1385
|
+
<tbody class="divide-y divide-border">
|
|
1386
|
+
{s.queues.map((q) => (
|
|
1387
|
+
<tr key={q.name} class="hover:bg-accent/60">
|
|
1388
|
+
<td class="px-5 py-2.5 font-mono text-xs text-foreground font-medium">
|
|
1389
|
+
<span>{q.name}</span>
|
|
1390
|
+
{q.paused ? (
|
|
1391
|
+
<span class="ml-2 text-[9px] font-bold px-1 py-0.5 rounded bg-warning/20 text-warning uppercase">
|
|
1392
|
+
paused
|
|
1393
|
+
</span>
|
|
1394
|
+
) : (
|
|
1395
|
+
""
|
|
1396
|
+
)}
|
|
1397
|
+
</td>
|
|
1398
|
+
<td
|
|
1399
|
+
class={[
|
|
1400
|
+
"px-3 py-2.5 tabular",
|
|
1401
|
+
q.pending > 200 ? "text-destructive font-semibold" : "text-muted-foreground",
|
|
1402
|
+
]}
|
|
1403
|
+
>
|
|
1404
|
+
{q.pending}
|
|
1405
|
+
</td>
|
|
1406
|
+
<td class="px-3 py-2.5 tabular text-muted-foreground">{q.wait}s</td>
|
|
1407
|
+
<td class="px-3 py-2.5 tabular text-muted-foreground">{q.throughput}/min</td>
|
|
1408
|
+
<td class="px-3 py-2.5 w-32">
|
|
1409
|
+
<Sparkline series={q.series.length ? q.series : [0, 0]} class="w-24 h-5" />
|
|
1410
|
+
</td>
|
|
1411
|
+
<td class="px-3 py-2.5 text-right">
|
|
1412
|
+
<button
|
|
1413
|
+
onClick={() => this.pauseQueue(q.name)}
|
|
1414
|
+
class={[
|
|
1415
|
+
"text-[11px] font-semibold px-2 py-1 rounded-md border transition-colors",
|
|
1416
|
+
q.paused
|
|
1417
|
+
? "border-warning/40 text-warning bg-warning/10"
|
|
1418
|
+
: "border-border text-muted-foreground hover:border-primary/40 hover:text-primary",
|
|
1419
|
+
]}
|
|
1420
|
+
>
|
|
1421
|
+
{q.paused ? "Resume" : "Pause"}
|
|
1422
|
+
</button>
|
|
1423
|
+
</td>
|
|
1424
|
+
</tr>
|
|
1425
|
+
))}
|
|
1426
|
+
</tbody>
|
|
1427
|
+
</table>
|
|
1428
|
+
</Card>
|
|
1429
|
+
|
|
1430
|
+
<Card class="overflow-hidden">
|
|
1431
|
+
<div class="px-5 py-3 border-b border-border flex flex-wrap items-center gap-2">
|
|
1432
|
+
<span class="font-semibold text-foreground text-sm">Failed jobs</span>
|
|
1433
|
+
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-destructive/10 text-destructive">
|
|
1434
|
+
{failed.length}
|
|
1435
|
+
</span>
|
|
1436
|
+
<div class="ml-auto flex items-center gap-1">
|
|
1437
|
+
{s.jobTags.map((t) => (
|
|
1438
|
+
<button
|
|
1439
|
+
key={t}
|
|
1440
|
+
onClick={() => this.setTag(t)}
|
|
1441
|
+
class={[
|
|
1442
|
+
"text-[11px] font-semibold px-2 py-1 rounded-md capitalize transition-colors",
|
|
1443
|
+
this.activeTag === t
|
|
1444
|
+
? "bg-primary/10 text-primary"
|
|
1445
|
+
: "text-muted-foreground hover:bg-accent",
|
|
1446
|
+
]}
|
|
1447
|
+
>
|
|
1448
|
+
{t}
|
|
1449
|
+
</button>
|
|
1450
|
+
))}
|
|
1451
|
+
</div>
|
|
1452
|
+
</div>
|
|
1453
|
+
<div class="divide-y divide-border">
|
|
1454
|
+
{failed.map((j) => (
|
|
1455
|
+
<div key={j.id} class="flex items-center gap-3 px-5 py-3 text-sm">
|
|
1456
|
+
<div class="min-w-0">
|
|
1457
|
+
<div class="flex items-center gap-2">
|
|
1458
|
+
<span class="font-mono text-xs font-semibold text-foreground truncate">
|
|
1459
|
+
{j.name}
|
|
1460
|
+
</span>
|
|
1461
|
+
{j.tags.map((tg) => (
|
|
1462
|
+
<span
|
|
1463
|
+
key={tg}
|
|
1464
|
+
class="text-[9px] font-bold px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase"
|
|
1465
|
+
>
|
|
1466
|
+
{tg}
|
|
1467
|
+
</span>
|
|
1468
|
+
))}
|
|
1469
|
+
<span class="text-[10px] text-muted-foreground whitespace-nowrap">
|
|
1470
|
+
attempt {j.attempts}
|
|
1471
|
+
</span>
|
|
1472
|
+
</div>
|
|
1473
|
+
<div class="text-[11px] text-destructive truncate">{j.error}</div>
|
|
1474
|
+
</div>
|
|
1475
|
+
<span class="ml-auto text-[11px] text-muted-foreground tabular whitespace-nowrap">
|
|
1476
|
+
{j.failedAt}
|
|
1477
|
+
</span>
|
|
1478
|
+
<button
|
|
1479
|
+
onClick={() => this.retryJob(j.id)}
|
|
1480
|
+
class="text-[11px] font-semibold px-2.5 py-1 rounded-md border border-border text-muted-foreground hover:border-primary/40 hover:text-primary transition-colors"
|
|
1481
|
+
>
|
|
1482
|
+
Retry
|
|
1483
|
+
</button>
|
|
1484
|
+
</div>
|
|
1485
|
+
))}
|
|
1486
|
+
{failed.length === 0 ? (
|
|
1487
|
+
<div class="px-5 py-8 text-center text-sm text-muted-foreground">
|
|
1488
|
+
No failed jobs for this tag 🎉
|
|
1489
|
+
</div>
|
|
1490
|
+
) : (
|
|
1491
|
+
""
|
|
1492
|
+
)}
|
|
1493
|
+
</div>
|
|
1494
|
+
</Card>
|
|
1495
|
+
|
|
1496
|
+
<div class="grid lg:grid-cols-2 gap-6">
|
|
1497
|
+
<Card class="overflow-hidden">
|
|
1498
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
1499
|
+
Scheduled / delayed
|
|
1500
|
+
</div>
|
|
1501
|
+
<div class="divide-y divide-border">
|
|
1502
|
+
{s.scheduledJobs.map((sj) => (
|
|
1503
|
+
<div key={sj.name} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
1504
|
+
<span class="font-mono text-xs text-foreground truncate">{sj.name}</span>
|
|
1505
|
+
<span class="text-[9px] font-bold px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase">
|
|
1506
|
+
{sj.tag}
|
|
1507
|
+
</span>
|
|
1508
|
+
<span class="ml-auto text-[11px] text-muted-foreground">
|
|
1509
|
+
on <span class="font-mono">{sj.queue}</span>
|
|
1510
|
+
</span>
|
|
1511
|
+
<span class="text-xs font-semibold text-primary tabular w-14 text-right">
|
|
1512
|
+
{sj.runsIn === "—" ? "—" : `in ${sj.runsIn}`}
|
|
1513
|
+
</span>
|
|
1514
|
+
</div>
|
|
1515
|
+
))}
|
|
1516
|
+
</div>
|
|
1517
|
+
</Card>
|
|
1518
|
+
<Card class="overflow-hidden">
|
|
1519
|
+
<div class="px-5 py-3 border-b border-border text-sm flex items-center gap-2">
|
|
1520
|
+
<span class="font-semibold text-foreground">Dead-letter</span>
|
|
1521
|
+
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
1522
|
+
{s.deadLetter.length}
|
|
1523
|
+
</span>
|
|
1524
|
+
<span class="ml-auto text-[11px] text-muted-foreground font-normal">
|
|
1525
|
+
exhausted all retries
|
|
1526
|
+
</span>
|
|
1527
|
+
</div>
|
|
1528
|
+
<div class="divide-y divide-border">
|
|
1529
|
+
{s.deadLetter.map((d) => (
|
|
1530
|
+
<div key={d.id} class="flex items-center gap-3 px-5 py-3 text-sm">
|
|
1531
|
+
<div class="min-w-0">
|
|
1532
|
+
<div class="font-mono text-xs font-semibold text-foreground truncate">
|
|
1533
|
+
{d.name}
|
|
1534
|
+
</div>
|
|
1535
|
+
<div class="text-[11px] text-muted-foreground truncate">{d.error}</div>
|
|
1536
|
+
</div>
|
|
1537
|
+
<span class="ml-auto text-[10px] text-muted-foreground whitespace-nowrap">
|
|
1538
|
+
{d.attempts} tries · {d.deadAt}
|
|
1539
|
+
</span>
|
|
1540
|
+
<button
|
|
1541
|
+
onClick={() => this.requeueDead(d.id)}
|
|
1542
|
+
class="text-[11px] font-semibold px-2.5 py-1 rounded-md border border-border text-muted-foreground hover:border-primary/40 hover:text-primary transition-colors"
|
|
1543
|
+
>
|
|
1544
|
+
Requeue
|
|
1545
|
+
</button>
|
|
1546
|
+
</div>
|
|
1547
|
+
))}
|
|
1548
|
+
{s.deadLetter.length === 0 ? (
|
|
1549
|
+
<div class="px-5 py-8 text-center text-sm text-muted-foreground">
|
|
1550
|
+
Dead-letter empty
|
|
1551
|
+
</div>
|
|
1552
|
+
) : (
|
|
1553
|
+
""
|
|
1554
|
+
)}
|
|
1555
|
+
</div>
|
|
1556
|
+
</Card>
|
|
1557
|
+
</div>
|
|
1558
|
+
|
|
1559
|
+
<Card class="overflow-hidden">
|
|
1560
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
1561
|
+
Slowest jobs
|
|
1562
|
+
</div>
|
|
1563
|
+
<div class="divide-y divide-border">
|
|
1564
|
+
{s.slowJobs.length === 0 ? (
|
|
1565
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
1566
|
+
no job runs in range
|
|
1567
|
+
</div>
|
|
1568
|
+
) : (
|
|
1569
|
+
s.slowJobs.map((j, i) => (
|
|
1570
|
+
<div key={i} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
1571
|
+
<span
|
|
1572
|
+
class={[
|
|
1573
|
+
"w-2 h-2 rounded-full shrink-0",
|
|
1574
|
+
j.status === "failed"
|
|
1575
|
+
? "bg-destructive"
|
|
1576
|
+
: j.status === "retried"
|
|
1577
|
+
? "bg-warning"
|
|
1578
|
+
: "bg-success",
|
|
1579
|
+
]}
|
|
1580
|
+
/>
|
|
1581
|
+
<span class="font-mono text-xs text-foreground truncate flex-1">
|
|
1582
|
+
{j.className}
|
|
1583
|
+
</span>
|
|
1584
|
+
<span class="text-[11px] text-muted-foreground shrink-0">{j.queue}</span>
|
|
1585
|
+
<span class="tabular text-xs font-semibold text-muted-foreground shrink-0">
|
|
1586
|
+
{j.ms}ms
|
|
1587
|
+
</span>
|
|
1588
|
+
<span class="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">
|
|
1589
|
+
{j.when}
|
|
1590
|
+
</span>
|
|
1591
|
+
</div>
|
|
1592
|
+
))
|
|
1593
|
+
)}
|
|
1594
|
+
</div>
|
|
1595
|
+
</Card>
|
|
1596
|
+
</section>
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// ── Requests (trace explorer) ───────────────────────────────────────────────
|
|
1601
|
+
|
|
1602
|
+
private renderRequests(): HtmlNode {
|
|
1603
|
+
const all = this.filteredRequests();
|
|
1604
|
+
const totalPages = Math.max(1, Math.ceil(all.length / REQ_PER_PAGE));
|
|
1605
|
+
const page = Math.min(Math.max(0, this.reqPage), totalPages - 1);
|
|
1606
|
+
const start = page * REQ_PER_PAGE;
|
|
1607
|
+
const pageRows = all.slice(start, start + REQ_PER_PAGE);
|
|
1608
|
+
return (
|
|
1609
|
+
<section class="space-y-4">
|
|
1610
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
1611
|
+
<div class="relative flex-1 min-w-[200px]">
|
|
1612
|
+
<svg
|
|
1613
|
+
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
1614
|
+
viewBox="0 0 20 20"
|
|
1615
|
+
fill="none"
|
|
1616
|
+
stroke="currentColor"
|
|
1617
|
+
stroke-width="1.7"
|
|
1618
|
+
>
|
|
1619
|
+
<circle cx="9" cy="9" r="6" />
|
|
1620
|
+
<path d="M14 14l3 3" stroke-linecap="round" />
|
|
1621
|
+
</svg>
|
|
1622
|
+
<input
|
|
1623
|
+
value={this.q}
|
|
1624
|
+
live
|
|
1625
|
+
type="text"
|
|
1626
|
+
placeholder="Filter by path or method…"
|
|
1627
|
+
class="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50"
|
|
1628
|
+
/>
|
|
1629
|
+
</div>
|
|
1630
|
+
<div class="flex items-center p-0.5 rounded-lg bg-muted border border-border">
|
|
1631
|
+
{[
|
|
1632
|
+
["all", "All"],
|
|
1633
|
+
["ok", "2xx/3xx"],
|
|
1634
|
+
["err", "4xx/5xx"],
|
|
1635
|
+
].map((f) => (
|
|
1636
|
+
<button
|
|
1637
|
+
key={f[0]}
|
|
1638
|
+
onClick={() => this.setStatusFilter(f[0]!)}
|
|
1639
|
+
class={[
|
|
1640
|
+
"px-2.5 py-1 rounded-md text-xs font-semibold transition-colors",
|
|
1641
|
+
this.statusFilter === f[0]
|
|
1642
|
+
? "bg-card text-foreground shadow-sm"
|
|
1643
|
+
: "text-muted-foreground hover:text-foreground",
|
|
1644
|
+
]}
|
|
1645
|
+
>
|
|
1646
|
+
{f[1]}
|
|
1647
|
+
</button>
|
|
1648
|
+
))}
|
|
1649
|
+
</div>
|
|
1650
|
+
</div>
|
|
1651
|
+
|
|
1652
|
+
{this.renderRequestMetrics()}
|
|
1653
|
+
|
|
1654
|
+
<Card class="overflow-hidden">
|
|
1655
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
1656
|
+
<span class="font-semibold text-foreground text-sm">
|
|
1657
|
+
Recent requests ·{" "}
|
|
1658
|
+
<span class="text-muted-foreground font-normal">{all.length} shown</span>
|
|
1659
|
+
</span>
|
|
1660
|
+
<span class="text-xs text-muted-foreground">click a row for its trace</span>
|
|
1661
|
+
</div>
|
|
1662
|
+
<div class="overflow-x-auto">
|
|
1663
|
+
<table class="w-full text-sm">
|
|
1664
|
+
<thead class="text-left text-[11px] uppercase tracking-wide text-muted-foreground bg-muted">
|
|
1665
|
+
<tr>
|
|
1666
|
+
<th class="px-5 py-2 font-medium">Method</th>
|
|
1667
|
+
<th class="px-3 py-2 font-medium">Path</th>
|
|
1668
|
+
<th class="px-3 py-2 font-medium">Status</th>
|
|
1669
|
+
<th class="px-3 py-2 font-medium">User</th>
|
|
1670
|
+
<th class="px-3 py-2 font-medium">IP</th>
|
|
1671
|
+
<th class="px-3 py-2 font-medium">Queries</th>
|
|
1672
|
+
<th class="px-3 py-2 font-medium">Memory</th>
|
|
1673
|
+
<th class="px-3 py-2 font-medium">Time</th>
|
|
1674
|
+
<th class="px-3 py-2 font-medium">When</th>
|
|
1675
|
+
</tr>
|
|
1676
|
+
</thead>
|
|
1677
|
+
<tbody class="divide-y divide-border">
|
|
1678
|
+
{pageRows.length === 0 ? (
|
|
1679
|
+
<tr>
|
|
1680
|
+
<td colspan="9" class="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
1681
|
+
no requests in this range
|
|
1682
|
+
</td>
|
|
1683
|
+
</tr>
|
|
1684
|
+
) : (
|
|
1685
|
+
pageRows.map((r) => this.renderRequestRows(r))
|
|
1686
|
+
)}
|
|
1687
|
+
</tbody>
|
|
1688
|
+
</table>
|
|
1689
|
+
</div>
|
|
1690
|
+
{all.length > 0 ? (
|
|
1691
|
+
<div class="px-5 py-2.5 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
|
|
1692
|
+
<span class="tabular">
|
|
1693
|
+
{start + 1}–{Math.min(start + REQ_PER_PAGE, all.length)} of {all.length}
|
|
1694
|
+
</span>
|
|
1695
|
+
<div class="flex items-center gap-1">
|
|
1696
|
+
<button
|
|
1697
|
+
onClick={this.prevReqPage}
|
|
1698
|
+
class={[
|
|
1699
|
+
"px-2.5 py-1 rounded-md border text-xs font-semibold transition-colors",
|
|
1700
|
+
page === 0
|
|
1701
|
+
? "border-border text-muted-foreground cursor-not-allowed"
|
|
1702
|
+
: "border-border text-muted-foreground hover:border-primary/40 hover:text-primary",
|
|
1703
|
+
]}
|
|
1704
|
+
>
|
|
1705
|
+
Prev
|
|
1706
|
+
</button>
|
|
1707
|
+
<span class="px-2 tabular text-muted-foreground">
|
|
1708
|
+
{page + 1} / {totalPages}
|
|
1709
|
+
</span>
|
|
1710
|
+
<button
|
|
1711
|
+
onClick={this.nextReqPage}
|
|
1712
|
+
class={[
|
|
1713
|
+
"px-2.5 py-1 rounded-md border text-xs font-semibold transition-colors",
|
|
1714
|
+
page >= totalPages - 1
|
|
1715
|
+
? "border-border text-muted-foreground cursor-not-allowed"
|
|
1716
|
+
: "border-border text-muted-foreground hover:border-primary/40 hover:text-primary",
|
|
1717
|
+
]}
|
|
1718
|
+
>
|
|
1719
|
+
Next
|
|
1720
|
+
</button>
|
|
1721
|
+
</div>
|
|
1722
|
+
</div>
|
|
1723
|
+
) : (
|
|
1724
|
+
""
|
|
1725
|
+
)}
|
|
1726
|
+
</Card>
|
|
1727
|
+
</section>
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** Format a memory figure (KB) as KB/MB, or "—" when unknown. */
|
|
1732
|
+
private _fmtMem(kb: number): string {
|
|
1733
|
+
if (!kb || kb <= 0) return "—";
|
|
1734
|
+
return kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** Render `Monitor.context()` metadata as key=value chips. */
|
|
1738
|
+
private _contextChips(context: Record<string, unknown>): HtmlNode {
|
|
1739
|
+
return (
|
|
1740
|
+
<span class="flex flex-wrap items-center gap-1.5">
|
|
1741
|
+
{Object.entries(context).map(([k, v]) => (
|
|
1742
|
+
<span
|
|
1743
|
+
key={k}
|
|
1744
|
+
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px] font-medium"
|
|
1745
|
+
>
|
|
1746
|
+
<span class="text-primary/80">{k}</span>
|
|
1747
|
+
<span class="font-mono">{typeof v === "object" ? JSON.stringify(v) : String(v)}</span>
|
|
1748
|
+
</span>
|
|
1749
|
+
))}
|
|
1750
|
+
</span>
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/** Slow requests, application usage (top users), and top-memory routes. */
|
|
1755
|
+
private renderRequestMetrics(): HtmlNode {
|
|
1756
|
+
const s = this.snap;
|
|
1757
|
+
const maxUser = Math.max(1, ...s.topUsers.map((u) => u.requests));
|
|
1758
|
+
const maxMem = Math.max(1, ...s.topMemory.map((m) => m.memKb));
|
|
1759
|
+
return (
|
|
1760
|
+
<div class="grid lg:grid-cols-3 gap-4">
|
|
1761
|
+
{/* Slow requests */}
|
|
1762
|
+
<Card class="overflow-hidden">
|
|
1763
|
+
<div class="px-4 py-2.5 border-b border-border flex items-center justify-between">
|
|
1764
|
+
<span class="text-sm font-semibold text-foreground">Slow requests</span>
|
|
1765
|
+
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-destructive/10 text-destructive">
|
|
1766
|
+
{s.slowRequests.length}
|
|
1767
|
+
</span>
|
|
1768
|
+
</div>
|
|
1769
|
+
<div class="divide-y divide-border max-h-56 overflow-y-auto">
|
|
1770
|
+
{s.slowRequests.length === 0 ? (
|
|
1771
|
+
<div class="px-4 py-6 text-center text-xs text-muted-foreground">
|
|
1772
|
+
none over threshold
|
|
1773
|
+
</div>
|
|
1774
|
+
) : (
|
|
1775
|
+
s.slowRequests.map((r) => (
|
|
1776
|
+
<button
|
|
1777
|
+
key={`sr${r.id}`}
|
|
1778
|
+
onClick={() => this.openReq(r.id)}
|
|
1779
|
+
class="w-full flex items-center gap-2 px-4 py-2 text-left hover:bg-accent"
|
|
1780
|
+
>
|
|
1781
|
+
<span
|
|
1782
|
+
class={[
|
|
1783
|
+
"text-[9px] font-bold px-1 py-0.5 rounded shrink-0",
|
|
1784
|
+
methodTone(r.method),
|
|
1785
|
+
]}
|
|
1786
|
+
>
|
|
1787
|
+
{r.method}
|
|
1788
|
+
</span>
|
|
1789
|
+
<span class="font-mono text-[11px] text-muted-foreground truncate flex-1">
|
|
1790
|
+
{r.path}
|
|
1791
|
+
</span>
|
|
1792
|
+
<span class="tabular text-xs font-semibold text-destructive shrink-0">
|
|
1793
|
+
{r.ms}ms
|
|
1794
|
+
</span>
|
|
1795
|
+
</button>
|
|
1796
|
+
))
|
|
1797
|
+
)}
|
|
1798
|
+
</div>
|
|
1799
|
+
</Card>
|
|
1800
|
+
|
|
1801
|
+
{/* Application usage — top users */}
|
|
1802
|
+
<Card class="overflow-hidden">
|
|
1803
|
+
<div class="px-4 py-2.5 border-b border-border">
|
|
1804
|
+
<span class="text-sm font-semibold text-foreground">Application usage</span>
|
|
1805
|
+
<span class="text-[11px] text-muted-foreground ml-1">· top users</span>
|
|
1806
|
+
</div>
|
|
1807
|
+
<div class="divide-y divide-border max-h-56 overflow-y-auto">
|
|
1808
|
+
{s.topUsers.length === 0 ? (
|
|
1809
|
+
<div class="px-4 py-6 text-center text-xs text-muted-foreground">
|
|
1810
|
+
no authenticated traffic
|
|
1811
|
+
</div>
|
|
1812
|
+
) : (
|
|
1813
|
+
s.topUsers.map((u) => (
|
|
1814
|
+
<div key={u.id} class="flex items-center gap-3 px-4 py-2">
|
|
1815
|
+
<span class="font-mono text-[11px] text-muted-foreground truncate flex-1">
|
|
1816
|
+
{u.id}
|
|
1817
|
+
</span>
|
|
1818
|
+
<div class="w-20 h-1.5 rounded-full bg-muted overflow-hidden shrink-0">
|
|
1819
|
+
<div
|
|
1820
|
+
class="h-full bg-primary/80"
|
|
1821
|
+
style={`width:${Math.round((u.requests / maxUser) * 100)}%`}
|
|
1822
|
+
/>
|
|
1823
|
+
</div>
|
|
1824
|
+
<span class="tabular text-xs font-semibold text-foreground w-10 text-right shrink-0">
|
|
1825
|
+
{commas(u.requests)}
|
|
1826
|
+
</span>
|
|
1827
|
+
</div>
|
|
1828
|
+
))
|
|
1829
|
+
)}
|
|
1830
|
+
</div>
|
|
1831
|
+
</Card>
|
|
1832
|
+
|
|
1833
|
+
{/* Top memory routes */}
|
|
1834
|
+
<Card class="overflow-hidden">
|
|
1835
|
+
<div class="px-4 py-2.5 border-b border-border">
|
|
1836
|
+
<span class="text-sm font-semibold text-foreground">Top memory</span>
|
|
1837
|
+
<span class="text-[11px] text-muted-foreground ml-1">· peak heap / route</span>
|
|
1838
|
+
</div>
|
|
1839
|
+
<div class="divide-y divide-border max-h-56 overflow-y-auto">
|
|
1840
|
+
{s.topMemory.length === 0 ? (
|
|
1841
|
+
<div class="px-4 py-6 text-center text-xs text-muted-foreground">no data yet</div>
|
|
1842
|
+
) : (
|
|
1843
|
+
s.topMemory.map((m) => (
|
|
1844
|
+
<div key={`${m.method}${m.path}`} class="flex items-center gap-2 px-4 py-2">
|
|
1845
|
+
<span class="font-mono text-[11px] text-muted-foreground truncate flex-1">
|
|
1846
|
+
{m.path}
|
|
1847
|
+
</span>
|
|
1848
|
+
<span
|
|
1849
|
+
class={[
|
|
1850
|
+
"tabular text-xs font-semibold shrink-0",
|
|
1851
|
+
m.memKb >= maxMem * 0.8 ? "text-warning" : "text-muted-foreground",
|
|
1852
|
+
]}
|
|
1853
|
+
>
|
|
1854
|
+
{this._fmtMem(m.memKb)}
|
|
1855
|
+
</span>
|
|
1856
|
+
</div>
|
|
1857
|
+
))
|
|
1858
|
+
)}
|
|
1859
|
+
</div>
|
|
1860
|
+
</Card>
|
|
1861
|
+
</div>
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
private renderRequestRows(r: RequestEntry): HtmlNode[] {
|
|
1866
|
+
const open = this.openRequestId === r.id;
|
|
1867
|
+
const rows: HtmlNode[] = [
|
|
1868
|
+
<tr
|
|
1869
|
+
key={`r${r.id}`}
|
|
1870
|
+
class="hover:bg-accent/60 cursor-pointer"
|
|
1871
|
+
onClick={() => this.openReq(r.id)}
|
|
1872
|
+
>
|
|
1873
|
+
<td class="px-5 py-2.5">
|
|
1874
|
+
<span class={["text-[10px] font-bold px-1.5 py-0.5 rounded", methodTone(r.method)]}>
|
|
1875
|
+
{r.method}
|
|
1876
|
+
</span>
|
|
1877
|
+
</td>
|
|
1878
|
+
<td class="px-3 py-2.5 font-mono text-xs text-foreground truncate max-w-[220px]">
|
|
1879
|
+
{r.path}
|
|
1880
|
+
</td>
|
|
1881
|
+
<td class="px-3 py-2.5">
|
|
1882
|
+
<span class={["tabular font-semibold", statusTone(r.status)]}>{r.status}</span>
|
|
1883
|
+
</td>
|
|
1884
|
+
<td class="px-3 py-2.5 text-xs text-muted-foreground truncate max-w-[140px]">
|
|
1885
|
+
{r.user ?? "—"}
|
|
1886
|
+
</td>
|
|
1887
|
+
<td class="px-3 py-2.5 font-mono text-[11px] text-muted-foreground whitespace-nowrap">
|
|
1888
|
+
{r.ip ?? "—"}
|
|
1889
|
+
</td>
|
|
1890
|
+
<td class="px-3 py-2.5 tabular text-muted-foreground">
|
|
1891
|
+
{r.queries.length}
|
|
1892
|
+
{r.nplus ? <span class="ml-1 text-[10px] font-bold text-destructive">N+1</span> : ""}
|
|
1893
|
+
</td>
|
|
1894
|
+
<td class="px-3 py-2.5 tabular text-xs text-muted-foreground whitespace-nowrap">
|
|
1895
|
+
{this._fmtMem(r.memKb)}
|
|
1896
|
+
</td>
|
|
1897
|
+
<td
|
|
1898
|
+
class={[
|
|
1899
|
+
"px-3 py-2.5 tabular",
|
|
1900
|
+
r.ms > 500 ? "text-destructive" : r.ms > 200 ? "text-warning" : "text-muted-foreground",
|
|
1901
|
+
]}
|
|
1902
|
+
>
|
|
1903
|
+
{r.ms}ms
|
|
1904
|
+
</td>
|
|
1905
|
+
<td class="px-3 py-2.5 text-[11px] text-muted-foreground tabular whitespace-nowrap">
|
|
1906
|
+
{r.when}
|
|
1907
|
+
</td>
|
|
1908
|
+
</tr>,
|
|
1909
|
+
];
|
|
1910
|
+
if (open) {
|
|
1911
|
+
rows.push(
|
|
1912
|
+
<tr key={`d${r.id}`}>
|
|
1913
|
+
<td colspan="9" class="px-5 pb-4 pt-1 bg-muted/50">
|
|
1914
|
+
<div class="flex flex-wrap items-center gap-x-6 gap-y-1 px-1 py-2 text-[11px] text-muted-foreground">
|
|
1915
|
+
<span>
|
|
1916
|
+
user <span class="font-mono text-foreground">{r.user ?? "guest"}</span>
|
|
1917
|
+
</span>
|
|
1918
|
+
<span>
|
|
1919
|
+
ip <span class="font-mono text-foreground">{r.ip ?? "—"}</span>
|
|
1920
|
+
</span>
|
|
1921
|
+
<span>
|
|
1922
|
+
memory <span class="font-mono text-foreground">{this._fmtMem(r.memKb)}</span>
|
|
1923
|
+
</span>
|
|
1924
|
+
{Object.keys(r.context).length > 0 ? this._contextChips(r.context) : ""}
|
|
1925
|
+
<button
|
|
1926
|
+
onClick={() => this.openRoute(r.method, r.path)}
|
|
1927
|
+
class="ml-auto font-semibold text-primary hover:text-primary"
|
|
1928
|
+
>
|
|
1929
|
+
View route history →
|
|
1930
|
+
</button>
|
|
1931
|
+
</div>
|
|
1932
|
+
{r.error ? (
|
|
1933
|
+
<div class="rounded-lg bg-destructive/10 border border-destructive/30 px-4 py-2.5 mb-2 flex items-start gap-2">
|
|
1934
|
+
<span class="mt-0.5 w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
|
1935
|
+
<span class="font-mono text-[11px] text-destructive break-all">{r.error}</span>
|
|
1936
|
+
</div>
|
|
1937
|
+
) : (
|
|
1938
|
+
""
|
|
1939
|
+
)}
|
|
1940
|
+
<div class="rounded-lg bg-card border border-border p-4 mb-2">
|
|
1941
|
+
<div class="flex items-center justify-between mb-2">
|
|
1942
|
+
<span class="text-xs font-semibold text-foreground">Trace waterfall</span>
|
|
1943
|
+
<span class="text-[11px] text-muted-foreground tabular">total {r.ms}ms</span>
|
|
1944
|
+
</div>
|
|
1945
|
+
<div class="space-y-1">
|
|
1946
|
+
{r.spans.map((sp, si) => (
|
|
1947
|
+
<div key={si} class="flex items-center gap-2 text-[11px]">
|
|
1948
|
+
<span class="w-36 shrink-0 font-mono text-muted-foreground truncate">
|
|
1949
|
+
{sp.label}
|
|
1950
|
+
</span>
|
|
1951
|
+
<div class="relative flex-1 h-3.5 rounded bg-muted">
|
|
1952
|
+
<div
|
|
1953
|
+
class="absolute top-0 h-3.5 rounded"
|
|
1954
|
+
style={`left:${((sp.start / r.ms) * 100).toFixed(1)}%;width:${Math.max(1.5, (sp.dur / r.ms) * 100).toFixed(1)}%;background:${spanColor(sp.kind)}`}
|
|
1955
|
+
/>
|
|
1956
|
+
</div>
|
|
1957
|
+
<span class="w-12 shrink-0 text-right tabular text-muted-foreground">
|
|
1958
|
+
{sp.dur}ms
|
|
1959
|
+
</span>
|
|
1960
|
+
</div>
|
|
1961
|
+
))}
|
|
1962
|
+
</div>
|
|
1963
|
+
</div>
|
|
1964
|
+
<div class="rounded-lg bg-foreground p-4 font-mono text-[11px] text-muted-foreground space-y-1 overflow-x-auto">
|
|
1965
|
+
<div class="text-muted-foreground">— SQL queries ({r.queries.length}) —</div>
|
|
1966
|
+
{r.queries.map((q, qi) => (
|
|
1967
|
+
<div key={qi} class="flex gap-3">
|
|
1968
|
+
<span class="text-primary/80 shrink-0 tabular">{q.ms}ms</span>
|
|
1969
|
+
<span class="text-muted-foreground truncate">{q.sql}</span>
|
|
1970
|
+
</div>
|
|
1971
|
+
))}
|
|
1972
|
+
{r.queries.length === 0 ? <div class="text-muted-foreground">no queries</div> : ""}
|
|
1973
|
+
<div class="text-muted-foreground pt-2">— log —</div>
|
|
1974
|
+
{r.logs.map((l, li) => (
|
|
1975
|
+
<div key={li}>
|
|
1976
|
+
<span
|
|
1977
|
+
class={
|
|
1978
|
+
l.level === "error"
|
|
1979
|
+
? "text-destructive/80"
|
|
1980
|
+
: l.level === "warn"
|
|
1981
|
+
? "text-warning/80"
|
|
1982
|
+
: "text-success/80"
|
|
1983
|
+
}
|
|
1984
|
+
>
|
|
1985
|
+
[{l.level}]
|
|
1986
|
+
</span>{" "}
|
|
1987
|
+
<span class="text-muted-foreground">{l.msg}</span>
|
|
1988
|
+
</div>
|
|
1989
|
+
))}
|
|
1990
|
+
</div>
|
|
1991
|
+
{r.payload ? this._renderPayload(r.payload) : ""}
|
|
1992
|
+
</td>
|
|
1993
|
+
</tr>,
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
return rows;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** Captured request/response headers + bodies (when payload capture is enabled). */
|
|
2000
|
+
private _renderPayload(p: import("../store/types.ts").RequestPayload): HtmlNode {
|
|
2001
|
+
const box = "rounded-lg bg-card border border-border overflow-hidden";
|
|
2002
|
+
const head =
|
|
2003
|
+
"px-3 py-1.5 text-[11px] font-semibold text-muted-foreground border-b border-border";
|
|
2004
|
+
const body =
|
|
2005
|
+
"p-3 font-mono text-[11px] text-muted-foreground whitespace-pre-wrap break-all max-h-44 overflow-y-auto";
|
|
2006
|
+
return (
|
|
2007
|
+
<div class="mt-2 grid lg:grid-cols-3 gap-2">
|
|
2008
|
+
<div class={box}>
|
|
2009
|
+
<div class={head}>Request headers</div>
|
|
2010
|
+
<div class="p-3 font-mono text-[11px] text-muted-foreground space-y-0.5 max-h-44 overflow-y-auto">
|
|
2011
|
+
{Object.entries(p.reqHeaders).map(([k, v]) => (
|
|
2012
|
+
<div key={k} class="flex gap-2">
|
|
2013
|
+
<span class="text-muted-foreground shrink-0">{k}:</span>
|
|
2014
|
+
<span class="truncate">{v}</span>
|
|
2015
|
+
</div>
|
|
2016
|
+
))}
|
|
2017
|
+
</div>
|
|
2018
|
+
</div>
|
|
2019
|
+
<div class={box}>
|
|
2020
|
+
<div class={head}>Request body</div>
|
|
2021
|
+
<pre class={body}>{p.reqBody || "—"}</pre>
|
|
2022
|
+
</div>
|
|
2023
|
+
<div class={box}>
|
|
2024
|
+
<div class={head}>Response body</div>
|
|
2025
|
+
<pre class={body}>{p.resBody || "—"}</pre>
|
|
2026
|
+
</div>
|
|
2027
|
+
</div>
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// ── Route detail (per-route drill-in) ───────────────────────────────────────
|
|
2032
|
+
|
|
2033
|
+
/** Tailwind classes colouring one status-class bar. */
|
|
2034
|
+
private _statusClassTone(label: string): string {
|
|
2035
|
+
return label === "2xx"
|
|
2036
|
+
? "bg-success"
|
|
2037
|
+
: label === "3xx"
|
|
2038
|
+
? "bg-muted-foreground"
|
|
2039
|
+
: label === "4xx"
|
|
2040
|
+
? "bg-warning"
|
|
2041
|
+
: "bg-destructive";
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/** One compact request row in the route-detail recent/slowest lists. */
|
|
2045
|
+
private _routeReqRow(r: RequestEntry, key: string): HtmlNode {
|
|
2046
|
+
return (
|
|
2047
|
+
<div key={key} class="flex items-center gap-3 px-5 py-2 text-sm">
|
|
2048
|
+
<span class={["tabular font-semibold w-10 shrink-0", statusTone(r.status)]}>
|
|
2049
|
+
{r.status}
|
|
2050
|
+
</span>
|
|
2051
|
+
<span class="text-xs text-muted-foreground truncate flex-1">{r.user ?? "guest"}</span>
|
|
2052
|
+
<span class="font-mono text-[11px] text-muted-foreground shrink-0 hidden sm:inline">
|
|
2053
|
+
{r.ip ?? "—"}
|
|
2054
|
+
</span>
|
|
2055
|
+
<span
|
|
2056
|
+
class={[
|
|
2057
|
+
"tabular text-xs font-semibold w-14 text-right shrink-0",
|
|
2058
|
+
r.ms > 500 ? "text-destructive" : r.ms > 200 ? "text-warning" : "text-muted-foreground",
|
|
2059
|
+
]}
|
|
2060
|
+
>
|
|
2061
|
+
{r.ms}ms
|
|
2062
|
+
</span>
|
|
2063
|
+
<span class="text-[11px] text-muted-foreground tabular w-16 text-right shrink-0 whitespace-nowrap">
|
|
2064
|
+
{r.when}
|
|
2065
|
+
</span>
|
|
2066
|
+
</div>
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
private renderRouteDetail(): HtmlNode {
|
|
2071
|
+
const d = this.routeDetail;
|
|
2072
|
+
if (!d) {
|
|
2073
|
+
return (
|
|
2074
|
+
<div class="grid place-items-center py-20 text-muted-foreground text-sm">
|
|
2075
|
+
Loading route…
|
|
2076
|
+
</div>
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
const maxStatus = Math.max(1, ...d.statusDist.map((s) => s.count));
|
|
2080
|
+
return (
|
|
2081
|
+
<section class="space-y-6">
|
|
2082
|
+
<div class="flex items-center gap-3 flex-wrap">
|
|
2083
|
+
<button
|
|
2084
|
+
onClick={this.backFromRoute}
|
|
2085
|
+
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold border border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-primary transition-colors"
|
|
2086
|
+
>
|
|
2087
|
+
← Back
|
|
2088
|
+
</button>
|
|
2089
|
+
<span class={["text-[11px] font-bold px-2 py-1 rounded", methodTone(d.method)]}>
|
|
2090
|
+
{d.method}
|
|
2091
|
+
</span>
|
|
2092
|
+
<span class="font-mono text-sm text-foreground truncate">{d.path}</span>
|
|
2093
|
+
<span class="text-[11px] text-muted-foreground ml-auto">{d.range} window</span>
|
|
2094
|
+
</div>
|
|
2095
|
+
|
|
2096
|
+
{d.total === 0 ? (
|
|
2097
|
+
<Card class="px-5 py-16 text-center text-sm text-muted-foreground">
|
|
2098
|
+
no requests to this route in the {d.range} window
|
|
2099
|
+
</Card>
|
|
2100
|
+
) : (
|
|
2101
|
+
<div class="space-y-6">
|
|
2102
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
2103
|
+
<Card class="p-4">
|
|
2104
|
+
<div class="text-xs font-medium text-muted-foreground">Requests</div>
|
|
2105
|
+
<div class="mt-1.5 text-2xl font-bold text-foreground tabular font-mono">
|
|
2106
|
+
{commas(d.total)}
|
|
2107
|
+
</div>
|
|
2108
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">{d.rpm}/min</div>
|
|
2109
|
+
</Card>
|
|
2110
|
+
<Card class="p-4">
|
|
2111
|
+
<div class="text-xs font-medium text-muted-foreground">Error rate</div>
|
|
2112
|
+
<div
|
|
2113
|
+
class={[
|
|
2114
|
+
"mt-1.5 text-2xl font-bold tabular font-mono",
|
|
2115
|
+
d.errorRate > 5
|
|
2116
|
+
? "text-destructive"
|
|
2117
|
+
: d.errorRate > 0
|
|
2118
|
+
? "text-warning"
|
|
2119
|
+
: "text-success",
|
|
2120
|
+
]}
|
|
2121
|
+
>
|
|
2122
|
+
{d.errorRate}%
|
|
2123
|
+
</div>
|
|
2124
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">
|
|
2125
|
+
{d.errorCount} of {commas(d.total)} are 5xx
|
|
2126
|
+
</div>
|
|
2127
|
+
</Card>
|
|
2128
|
+
<Card class="p-4">
|
|
2129
|
+
<div class="text-xs font-medium text-muted-foreground">p95 latency</div>
|
|
2130
|
+
<div class={["mt-1.5 text-2xl font-bold tabular font-mono", pctTone(d.p95)]}>
|
|
2131
|
+
{d.p95}ms
|
|
2132
|
+
</div>
|
|
2133
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">
|
|
2134
|
+
p50 {d.p50}ms · p99 {d.p99}ms
|
|
2135
|
+
</div>
|
|
2136
|
+
</Card>
|
|
2137
|
+
<Card class="p-4">
|
|
2138
|
+
<div class="text-xs font-medium text-muted-foreground">Avg response</div>
|
|
2139
|
+
<div class="mt-1.5 text-2xl font-bold text-foreground tabular font-mono">
|
|
2140
|
+
{d.avgMs}ms
|
|
2141
|
+
</div>
|
|
2142
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">max {d.maxMs}ms</div>
|
|
2143
|
+
</Card>
|
|
2144
|
+
</div>
|
|
2145
|
+
|
|
2146
|
+
<div class="grid lg:grid-cols-2 gap-6">
|
|
2147
|
+
<Card class="p-5">
|
|
2148
|
+
<div class="text-sm font-semibold text-foreground mb-1">Throughput</div>
|
|
2149
|
+
<div class="text-[11px] text-muted-foreground mb-2">
|
|
2150
|
+
requests over the {d.range} window
|
|
2151
|
+
</div>
|
|
2152
|
+
<Sparkline
|
|
2153
|
+
series={d.throughput.some((v) => v > 0) ? d.throughput : [0, 0]}
|
|
2154
|
+
class="w-full h-14 text-primary"
|
|
2155
|
+
/>
|
|
2156
|
+
</Card>
|
|
2157
|
+
<Card class="p-5">
|
|
2158
|
+
<div class="text-sm font-semibold text-foreground mb-1">Avg latency</div>
|
|
2159
|
+
<div class="text-[11px] text-muted-foreground mb-2">
|
|
2160
|
+
mean response time per bucket
|
|
2161
|
+
</div>
|
|
2162
|
+
<Sparkline
|
|
2163
|
+
series={d.latency.some((v) => v > 0) ? d.latency : [0, 0]}
|
|
2164
|
+
stroke="hsl(var(--chart-3))"
|
|
2165
|
+
class="w-full h-14"
|
|
2166
|
+
/>
|
|
2167
|
+
</Card>
|
|
2168
|
+
</div>
|
|
2169
|
+
|
|
2170
|
+
<Card class="overflow-hidden">
|
|
2171
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2172
|
+
Response status
|
|
2173
|
+
</div>
|
|
2174
|
+
<div class="p-5 space-y-2.5">
|
|
2175
|
+
{d.statusDist.map((sc) => (
|
|
2176
|
+
<div key={sc.label} class="flex items-center gap-3 text-sm">
|
|
2177
|
+
<span class="font-mono text-xs text-muted-foreground w-8 shrink-0">
|
|
2178
|
+
{sc.label}
|
|
2179
|
+
</span>
|
|
2180
|
+
<div class="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
|
|
2181
|
+
<div
|
|
2182
|
+
class={["h-full rounded-full", this._statusClassTone(sc.label)]}
|
|
2183
|
+
style={`width:${Math.round((sc.count / maxStatus) * 100)}%`}
|
|
2184
|
+
/>
|
|
2185
|
+
</div>
|
|
2186
|
+
<span class="tabular text-xs font-semibold text-muted-foreground w-12 text-right shrink-0">
|
|
2187
|
+
{commas(sc.count)}
|
|
2188
|
+
</span>
|
|
2189
|
+
</div>
|
|
2190
|
+
))}
|
|
2191
|
+
</div>
|
|
2192
|
+
</Card>
|
|
2193
|
+
|
|
2194
|
+
<div class="grid lg:grid-cols-2 gap-6">
|
|
2195
|
+
<Card class="overflow-hidden">
|
|
2196
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2197
|
+
Recent
|
|
2198
|
+
</div>
|
|
2199
|
+
<div class="divide-y divide-border">
|
|
2200
|
+
{d.recent.map((r) => this._routeReqRow(r, `rec${r.id}`))}
|
|
2201
|
+
</div>
|
|
2202
|
+
</Card>
|
|
2203
|
+
<Card class="overflow-hidden">
|
|
2204
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2205
|
+
Slowest
|
|
2206
|
+
</div>
|
|
2207
|
+
<div class="divide-y divide-border">
|
|
2208
|
+
{d.slowest.map((r) => this._routeReqRow(r, `slow${r.id}`))}
|
|
2209
|
+
</div>
|
|
2210
|
+
</Card>
|
|
2211
|
+
</div>
|
|
2212
|
+
</div>
|
|
2213
|
+
)}
|
|
2214
|
+
</section>
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// ── Exceptions ──────────────────────────────────────────────────────────────
|
|
2219
|
+
|
|
2220
|
+
private renderExceptions(): HtmlNode {
|
|
2221
|
+
return (
|
|
2222
|
+
<section class="space-y-3">
|
|
2223
|
+
{this.snap.exceptions.map((e) => {
|
|
2224
|
+
const open = this.openExc === e.type;
|
|
2225
|
+
return (
|
|
2226
|
+
<div key={e.type} class="rounded-xl bg-card border border-border shadow-sm p-4">
|
|
2227
|
+
<div
|
|
2228
|
+
class="flex items-start gap-3 cursor-pointer"
|
|
2229
|
+
onClick={() => this.toggleExc(e.type)}
|
|
2230
|
+
>
|
|
2231
|
+
<span class="mt-1 w-2 h-2 rounded-full bg-destructive shrink-0" />
|
|
2232
|
+
<div class="min-w-0 flex-1">
|
|
2233
|
+
<div class="font-mono text-sm font-semibold text-foreground">{e.type}</div>
|
|
2234
|
+
<div class="text-xs text-muted-foreground mt-0.5 truncate">{e.message}</div>
|
|
2235
|
+
<div class="text-[11px] text-muted-foreground mt-1 font-mono truncate">
|
|
2236
|
+
{e.location}
|
|
2237
|
+
</div>
|
|
2238
|
+
</div>
|
|
2239
|
+
<div class="text-right shrink-0">
|
|
2240
|
+
<div class="text-lg font-bold text-destructive tabular">{e.count}</div>
|
|
2241
|
+
<div class="text-[10px] text-muted-foreground">
|
|
2242
|
+
<span class="tabular">{e.users}</span> users · last <span>{e.lastSeen}</span>
|
|
2243
|
+
</div>
|
|
2244
|
+
</div>
|
|
2245
|
+
</div>
|
|
2246
|
+
<div class="flex items-center gap-4 mt-3">
|
|
2247
|
+
<div class="flex items-center gap-4 shrink-0">
|
|
2248
|
+
{[
|
|
2249
|
+
["24h", e.d1],
|
|
2250
|
+
["7d", e.d7],
|
|
2251
|
+
["30d", e.d30],
|
|
2252
|
+
].map((b) => (
|
|
2253
|
+
<div key={String(b[0])} class="text-center">
|
|
2254
|
+
<div class="text-sm font-bold text-foreground tabular">{b[1]}</div>
|
|
2255
|
+
<div class="text-[9px] uppercase tracking-wide text-muted-foreground">
|
|
2256
|
+
{b[0]}
|
|
2257
|
+
</div>
|
|
2258
|
+
</div>
|
|
2259
|
+
))}
|
|
2260
|
+
</div>
|
|
2261
|
+
<svg class="flex-1 h-7" viewBox="0 0 400 28" preserveAspectRatio="none">
|
|
2262
|
+
<polyline
|
|
2263
|
+
points={sparkPoints(e.series, 400, 26)}
|
|
2264
|
+
fill="none"
|
|
2265
|
+
stroke="hsl(var(--destructive))"
|
|
2266
|
+
stroke-width="1.5"
|
|
2267
|
+
vector-effect="non-scaling-stroke"
|
|
2268
|
+
/>
|
|
2269
|
+
</svg>
|
|
2270
|
+
<span class="text-[11px] text-muted-foreground shrink-0">
|
|
2271
|
+
{open ? "hide trace" : "show trace"}
|
|
2272
|
+
</span>
|
|
2273
|
+
</div>
|
|
2274
|
+
{open ? (
|
|
2275
|
+
<div class="mt-3 rounded-lg bg-foreground p-4 font-mono text-[11px] space-y-1 overflow-x-auto">
|
|
2276
|
+
<div class="text-destructive/80">
|
|
2277
|
+
{e.type}: {e.message}
|
|
2278
|
+
</div>
|
|
2279
|
+
{e.frames.map((f, fi) => (
|
|
2280
|
+
<div key={fi} class="text-muted-foreground">
|
|
2281
|
+
<span class="text-muted-foreground">at</span> <span>{f}</span>
|
|
2282
|
+
</div>
|
|
2283
|
+
))}
|
|
2284
|
+
</div>
|
|
2285
|
+
) : (
|
|
2286
|
+
""
|
|
2287
|
+
)}
|
|
2288
|
+
</div>
|
|
2289
|
+
);
|
|
2290
|
+
})}
|
|
2291
|
+
</section>
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// ── Database ────────────────────────────────────────────────────────────────
|
|
2296
|
+
|
|
2297
|
+
private renderDatabase(): HtmlNode {
|
|
2298
|
+
const s = this.snap;
|
|
2299
|
+
return (
|
|
2300
|
+
<section class="space-y-6">
|
|
2301
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
2302
|
+
{s.dbStats.map((m) => (
|
|
2303
|
+
<div key={m.label} class="rounded-xl bg-card border border-border p-4 shadow-sm">
|
|
2304
|
+
<div class="text-xs font-medium text-muted-foreground">{m.label}</div>
|
|
2305
|
+
<div class={["mt-1.5 text-2xl font-bold tabular font-mono", toneText(m.tone)]}>
|
|
2306
|
+
{m.value}
|
|
2307
|
+
</div>
|
|
2308
|
+
<div class="text-[11px] text-muted-foreground mt-0.5">{m.sub}</div>
|
|
2309
|
+
</div>
|
|
2310
|
+
))}
|
|
2311
|
+
</div>
|
|
2312
|
+
<Card class="overflow-hidden">
|
|
2313
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2314
|
+
Slowest queries
|
|
2315
|
+
</div>
|
|
2316
|
+
<div class="divide-y divide-border">
|
|
2317
|
+
{s.slowQueries.map((q, i) => (
|
|
2318
|
+
<div key={i} class="px-5 py-3">
|
|
2319
|
+
<div class="flex items-center gap-3">
|
|
2320
|
+
<span class="font-mono text-xs text-foreground truncate">{q.sql}</span>
|
|
2321
|
+
<span
|
|
2322
|
+
class={[
|
|
2323
|
+
"ml-auto tabular font-semibold shrink-0",
|
|
2324
|
+
q.ms > 400 ? "text-destructive" : "text-warning",
|
|
2325
|
+
]}
|
|
2326
|
+
>
|
|
2327
|
+
{q.ms}ms
|
|
2328
|
+
</span>
|
|
2329
|
+
</div>
|
|
2330
|
+
<div class="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground">
|
|
2331
|
+
<span>{q.callers} callers</span>
|
|
2332
|
+
<span>·</span>
|
|
2333
|
+
<span class="font-mono truncate">{q.location}</span>
|
|
2334
|
+
</div>
|
|
2335
|
+
</div>
|
|
2336
|
+
))}
|
|
2337
|
+
</div>
|
|
2338
|
+
</Card>
|
|
2339
|
+
|
|
2340
|
+
<div class="grid lg:grid-cols-2 gap-6">
|
|
2341
|
+
<Card class="overflow-hidden">
|
|
2342
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
2343
|
+
<span class="font-semibold text-foreground text-sm">N+1 query offenders</span>
|
|
2344
|
+
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-warning/10 text-warning">
|
|
2345
|
+
{s.nplusOnes.length}
|
|
2346
|
+
</span>
|
|
2347
|
+
</div>
|
|
2348
|
+
<div class="divide-y divide-border max-h-72 overflow-y-auto">
|
|
2349
|
+
{s.nplusOnes.length === 0 ? (
|
|
2350
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
2351
|
+
no N+1 detected 🎉
|
|
2352
|
+
</div>
|
|
2353
|
+
) : (
|
|
2354
|
+
s.nplusOnes.map((n, i) => (
|
|
2355
|
+
<div key={i} class="px-5 py-2.5">
|
|
2356
|
+
<div class="flex items-center gap-3">
|
|
2357
|
+
<span class="font-mono text-[11px] text-foreground truncate flex-1">
|
|
2358
|
+
{n.fingerprint}
|
|
2359
|
+
</span>
|
|
2360
|
+
<span class="tabular text-xs font-semibold text-warning shrink-0">
|
|
2361
|
+
×{n.worstCount}
|
|
2362
|
+
</span>
|
|
2363
|
+
</div>
|
|
2364
|
+
<div class="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
|
|
2365
|
+
{n.route ? <span class="font-mono truncate">{n.route}</span> : ""}
|
|
2366
|
+
<span class="ml-auto shrink-0">
|
|
2367
|
+
{n.occurrences} times · {n.lastSeen}
|
|
2368
|
+
</span>
|
|
2369
|
+
</div>
|
|
2370
|
+
</div>
|
|
2371
|
+
))
|
|
2372
|
+
)}
|
|
2373
|
+
</div>
|
|
2374
|
+
</Card>
|
|
2375
|
+
|
|
2376
|
+
<div class="space-y-6">
|
|
2377
|
+
<Card class="p-5">
|
|
2378
|
+
<div class="text-sm font-semibold text-foreground mb-3">Transactions</div>
|
|
2379
|
+
<div class="grid grid-cols-3 gap-3 text-center">
|
|
2380
|
+
<div>
|
|
2381
|
+
<div class="text-2xl font-bold text-success tabular">
|
|
2382
|
+
{commas(s.transactions.committed)}
|
|
2383
|
+
</div>
|
|
2384
|
+
<div class="text-[11px] text-muted-foreground">committed</div>
|
|
2385
|
+
</div>
|
|
2386
|
+
<div>
|
|
2387
|
+
<div
|
|
2388
|
+
class={[
|
|
2389
|
+
"text-2xl font-bold tabular",
|
|
2390
|
+
s.transactions.rolledBack > 0 ? "text-destructive" : "text-muted-foreground",
|
|
2391
|
+
]}
|
|
2392
|
+
>
|
|
2393
|
+
{commas(s.transactions.rolledBack)}
|
|
2394
|
+
</div>
|
|
2395
|
+
<div class="text-[11px] text-muted-foreground">rolled back</div>
|
|
2396
|
+
</div>
|
|
2397
|
+
<div>
|
|
2398
|
+
<div class="text-2xl font-bold text-foreground tabular">
|
|
2399
|
+
{s.transactions.avgMs}ms
|
|
2400
|
+
</div>
|
|
2401
|
+
<div class="text-[11px] text-muted-foreground">avg duration</div>
|
|
2402
|
+
</div>
|
|
2403
|
+
</div>
|
|
2404
|
+
</Card>
|
|
2405
|
+
<Card class="overflow-hidden">
|
|
2406
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2407
|
+
Recent migrations
|
|
2408
|
+
</div>
|
|
2409
|
+
<div class="divide-y divide-border max-h-44 overflow-y-auto">
|
|
2410
|
+
{s.migrations.length === 0 ? (
|
|
2411
|
+
<div class="px-5 py-6 text-center text-xs text-muted-foreground">
|
|
2412
|
+
none in range
|
|
2413
|
+
</div>
|
|
2414
|
+
) : (
|
|
2415
|
+
s.migrations.map((m, i) => (
|
|
2416
|
+
<div key={i} class="flex items-center gap-3 px-5 py-2 text-sm">
|
|
2417
|
+
<span
|
|
2418
|
+
class={[
|
|
2419
|
+
"w-2 h-2 rounded-full shrink-0",
|
|
2420
|
+
m.ok ? "bg-success" : "bg-destructive",
|
|
2421
|
+
]}
|
|
2422
|
+
/>
|
|
2423
|
+
<span class="font-mono text-[11px] text-foreground truncate flex-1">
|
|
2424
|
+
{m.name}
|
|
2425
|
+
</span>
|
|
2426
|
+
<span class="text-[10px] uppercase text-muted-foreground shrink-0">
|
|
2427
|
+
{m.direction}
|
|
2428
|
+
</span>
|
|
2429
|
+
<span class="tabular text-[11px] text-muted-foreground shrink-0">
|
|
2430
|
+
{m.ms}ms
|
|
2431
|
+
</span>
|
|
2432
|
+
<span class="text-[11px] text-muted-foreground shrink-0">{m.when}</span>
|
|
2433
|
+
</div>
|
|
2434
|
+
))
|
|
2435
|
+
)}
|
|
2436
|
+
</div>
|
|
2437
|
+
</Card>
|
|
2438
|
+
</div>
|
|
2439
|
+
</div>
|
|
2440
|
+
|
|
2441
|
+
{this.renderModelsPanel()}
|
|
2442
|
+
</section>
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/** Per-model created/updated/deleted counts — Telescope's models watcher. */
|
|
2447
|
+
private renderModelsPanel(): HtmlNode {
|
|
2448
|
+
const models = this.snap.models;
|
|
2449
|
+
return (
|
|
2450
|
+
<Card class="overflow-hidden">
|
|
2451
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
2452
|
+
<span class="font-semibold text-foreground text-sm">Model changes</span>
|
|
2453
|
+
<span class="text-xs text-muted-foreground">created · updated · deleted</span>
|
|
2454
|
+
</div>
|
|
2455
|
+
<div class="overflow-x-auto">
|
|
2456
|
+
<table class="w-full text-sm">
|
|
2457
|
+
<thead class="text-left text-[11px] uppercase tracking-wide text-muted-foreground bg-muted">
|
|
2458
|
+
<tr>
|
|
2459
|
+
<th class="px-5 py-2 font-medium">Model</th>
|
|
2460
|
+
<th class="px-3 py-2 font-medium">Table</th>
|
|
2461
|
+
<th class="px-3 py-2 font-medium text-right">Created</th>
|
|
2462
|
+
<th class="px-3 py-2 font-medium text-right">Updated</th>
|
|
2463
|
+
<th class="px-3 py-2 font-medium text-right">Deleted</th>
|
|
2464
|
+
<th class="px-3 py-2 font-medium text-right">Total</th>
|
|
2465
|
+
</tr>
|
|
2466
|
+
</thead>
|
|
2467
|
+
<tbody class="divide-y divide-border">
|
|
2468
|
+
{models.length === 0 ? (
|
|
2469
|
+
<tr>
|
|
2470
|
+
<td colspan="6" class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
2471
|
+
no model changes in this range
|
|
2472
|
+
</td>
|
|
2473
|
+
</tr>
|
|
2474
|
+
) : (
|
|
2475
|
+
models.map((m) => (
|
|
2476
|
+
<tr key={m.model} class="hover:bg-accent/60">
|
|
2477
|
+
<td class="px-5 py-2.5 font-mono text-xs text-foreground">{m.model}</td>
|
|
2478
|
+
<td class="px-3 py-2.5 font-mono text-[11px] text-muted-foreground">
|
|
2479
|
+
{m.table || "—"}
|
|
2480
|
+
</td>
|
|
2481
|
+
<td class="px-3 py-2.5 tabular text-right text-success font-semibold">
|
|
2482
|
+
{commas(m.created)}
|
|
2483
|
+
</td>
|
|
2484
|
+
<td class="px-3 py-2.5 tabular text-right text-warning font-semibold">
|
|
2485
|
+
{commas(m.updated)}
|
|
2486
|
+
</td>
|
|
2487
|
+
<td class="px-3 py-2.5 tabular text-right text-destructive font-semibold">
|
|
2488
|
+
{commas(m.deleted)}
|
|
2489
|
+
</td>
|
|
2490
|
+
<td class="px-3 py-2.5 tabular text-right text-foreground font-semibold">
|
|
2491
|
+
{commas(m.total)}
|
|
2492
|
+
</td>
|
|
2493
|
+
</tr>
|
|
2494
|
+
))
|
|
2495
|
+
)}
|
|
2496
|
+
</tbody>
|
|
2497
|
+
</table>
|
|
2498
|
+
</div>
|
|
2499
|
+
{this.snap.recentModels.length > 0 ? (
|
|
2500
|
+
<div class="border-t border-border">
|
|
2501
|
+
<div class="px-5 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
2502
|
+
Recent changes
|
|
2503
|
+
</div>
|
|
2504
|
+
<div class="divide-y divide-border max-h-56 overflow-y-auto">
|
|
2505
|
+
{this.snap.recentModels.map((m, i) => (
|
|
2506
|
+
<div key={i} class="flex items-center gap-3 px-5 py-1.5 text-sm">
|
|
2507
|
+
<span
|
|
2508
|
+
class={[
|
|
2509
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded uppercase shrink-0 w-16 text-center",
|
|
2510
|
+
m.operation === "created"
|
|
2511
|
+
? "bg-success/10 text-success"
|
|
2512
|
+
: m.operation === "updated"
|
|
2513
|
+
? "bg-warning/10 text-warning"
|
|
2514
|
+
: "bg-destructive/10 text-destructive",
|
|
2515
|
+
]}
|
|
2516
|
+
>
|
|
2517
|
+
{m.operation}
|
|
2518
|
+
</span>
|
|
2519
|
+
<span class="font-mono text-xs text-foreground truncate flex-1">{m.model}</span>
|
|
2520
|
+
<span class="font-mono text-[11px] text-muted-foreground shrink-0">
|
|
2521
|
+
{m.table || "—"}
|
|
2522
|
+
</span>
|
|
2523
|
+
<span class="text-[11px] text-muted-foreground tabular shrink-0 w-16 text-right">
|
|
2524
|
+
{m.when}
|
|
2525
|
+
</span>
|
|
2526
|
+
</div>
|
|
2527
|
+
))}
|
|
2528
|
+
</div>
|
|
2529
|
+
</div>
|
|
2530
|
+
) : (
|
|
2531
|
+
""
|
|
2532
|
+
)}
|
|
2533
|
+
</Card>
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// ── Cache ─────────────────────────────────────────────────────────────────
|
|
2538
|
+
|
|
2539
|
+
private renderCache(): HtmlNode {
|
|
2540
|
+
const c = this.snap.cache;
|
|
2541
|
+
return (
|
|
2542
|
+
<section class="space-y-6">
|
|
2543
|
+
<div class="grid sm:grid-cols-3 gap-4">
|
|
2544
|
+
<Card class="p-5 sm:col-span-1">
|
|
2545
|
+
<div class="text-xs font-medium text-muted-foreground">Hit rate</div>
|
|
2546
|
+
<div class="mt-2 flex items-end gap-2">
|
|
2547
|
+
<div class="text-4xl font-bold text-success tabular">{c.hitRate}%</div>
|
|
2548
|
+
</div>
|
|
2549
|
+
<div class="mt-3 h-2 rounded-full bg-muted overflow-hidden">
|
|
2550
|
+
<div class="h-full bg-success transition-all" style={`width:${c.hitRate}%`} />
|
|
2551
|
+
</div>
|
|
2552
|
+
<div class="mt-2 flex justify-between text-[11px] text-muted-foreground tabular">
|
|
2553
|
+
<span>{c.hits.toLocaleString()} hits</span>
|
|
2554
|
+
<span>{c.misses.toLocaleString()} misses</span>
|
|
2555
|
+
</div>
|
|
2556
|
+
<div class="mt-1 text-[11px] text-muted-foreground tabular border-t border-border pt-1.5">
|
|
2557
|
+
{c.evictions.toLocaleString()} evictions
|
|
2558
|
+
</div>
|
|
2559
|
+
</Card>
|
|
2560
|
+
<Card class="sm:col-span-2 overflow-hidden">
|
|
2561
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2562
|
+
Hottest keys
|
|
2563
|
+
</div>
|
|
2564
|
+
<div class="divide-y divide-border">
|
|
2565
|
+
{c.keys.map((k) => (
|
|
2566
|
+
<div key={k.key} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
2567
|
+
<span class="font-mono text-xs text-foreground truncate">{k.key}</span>
|
|
2568
|
+
<span class="ml-auto tabular text-xs text-muted-foreground">
|
|
2569
|
+
{k.hits.toLocaleString()} hits
|
|
2570
|
+
</span>
|
|
2571
|
+
<span
|
|
2572
|
+
class={[
|
|
2573
|
+
"tabular text-[11px] w-12 text-right",
|
|
2574
|
+
k.rate > 90 ? "text-success" : "text-warning",
|
|
2575
|
+
]}
|
|
2576
|
+
>
|
|
2577
|
+
{k.rate}%
|
|
2578
|
+
</span>
|
|
2579
|
+
</div>
|
|
2580
|
+
))}
|
|
2581
|
+
</div>
|
|
2582
|
+
</Card>
|
|
2583
|
+
</div>
|
|
2584
|
+
</section>
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// ── Mail ────────────────────────────────────────────────────────────────────
|
|
2589
|
+
|
|
2590
|
+
private renderMail(): HtmlNode {
|
|
2591
|
+
return (
|
|
2592
|
+
<section class="space-y-3">
|
|
2593
|
+
{this.snap.mail.map((m) => {
|
|
2594
|
+
const open = this.openMailId === m.id;
|
|
2595
|
+
return (
|
|
2596
|
+
<div
|
|
2597
|
+
key={m.id}
|
|
2598
|
+
class="rounded-xl bg-card border border-border shadow-sm overflow-hidden"
|
|
2599
|
+
>
|
|
2600
|
+
<div
|
|
2601
|
+
class="flex items-center gap-3 px-5 py-3 text-sm cursor-pointer hover:bg-accent/60"
|
|
2602
|
+
onClick={() => this.toggleMail(m.id)}
|
|
2603
|
+
>
|
|
2604
|
+
<span class="grid place-items-center w-8 h-8 rounded-lg bg-primary/10 text-primary shrink-0">
|
|
2605
|
+
<svg
|
|
2606
|
+
viewBox="0 0 20 20"
|
|
2607
|
+
fill="none"
|
|
2608
|
+
stroke="currentColor"
|
|
2609
|
+
stroke-width="1.6"
|
|
2610
|
+
class="w-4 h-4"
|
|
2611
|
+
>
|
|
2612
|
+
<rect x="2.5" y="4" width="15" height="12" rx="1.5" />
|
|
2613
|
+
<path d="M3 5l7 5.5L17 5" />
|
|
2614
|
+
</svg>
|
|
2615
|
+
</span>
|
|
2616
|
+
<div class="min-w-0">
|
|
2617
|
+
<div class="font-semibold text-foreground truncate">{m.subject}</div>
|
|
2618
|
+
<div class="text-[11px] text-muted-foreground">
|
|
2619
|
+
to <span>{m.to}</span> · <span class="font-mono">{m.mailer}</span>
|
|
2620
|
+
</div>
|
|
2621
|
+
</div>
|
|
2622
|
+
<span
|
|
2623
|
+
class={[
|
|
2624
|
+
"ml-auto text-[10px] font-bold px-1.5 py-0.5 rounded-full uppercase shrink-0",
|
|
2625
|
+
m.status === "sent"
|
|
2626
|
+
? "bg-success/10 text-success"
|
|
2627
|
+
: m.status === "queued"
|
|
2628
|
+
? "bg-warning/10 text-warning"
|
|
2629
|
+
: "bg-destructive/10 text-destructive",
|
|
2630
|
+
]}
|
|
2631
|
+
>
|
|
2632
|
+
{m.status}
|
|
2633
|
+
</span>
|
|
2634
|
+
<span class="text-[11px] text-muted-foreground whitespace-nowrap">{m.when}</span>
|
|
2635
|
+
</div>
|
|
2636
|
+
{open ? (
|
|
2637
|
+
<div class="border-t border-border bg-muted/50 p-5">
|
|
2638
|
+
{m.status === "sent" && /<\w+[\s/>]/.test(m.body) ? (
|
|
2639
|
+
// Render the email HTML in a sandboxed iframe (no scripts) — a real preview.
|
|
2640
|
+
<iframe
|
|
2641
|
+
srcdoc={m.body}
|
|
2642
|
+
sandbox=""
|
|
2643
|
+
title="Email preview"
|
|
2644
|
+
class="w-full h-96 rounded-lg bg-card border border-border"
|
|
2645
|
+
/>
|
|
2646
|
+
) : (
|
|
2647
|
+
<div class="rounded-lg bg-card border border-border p-4 text-sm text-muted-foreground whitespace-pre-line">
|
|
2648
|
+
{m.body}
|
|
2649
|
+
</div>
|
|
2650
|
+
)}
|
|
2651
|
+
{m.ms ? (
|
|
2652
|
+
<div class="text-[11px] text-muted-foreground mt-2">rendered in {m.ms}ms</div>
|
|
2653
|
+
) : (
|
|
2654
|
+
""
|
|
2655
|
+
)}
|
|
2656
|
+
</div>
|
|
2657
|
+
) : (
|
|
2658
|
+
""
|
|
2659
|
+
)}
|
|
2660
|
+
</div>
|
|
2661
|
+
);
|
|
2662
|
+
})}
|
|
2663
|
+
</section>
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// ── Shared feed controls (search + status filter + pagination) ───────────────
|
|
2668
|
+
|
|
2669
|
+
/** A search box, with an optional OK/Failed status filter, for a secondary feed. */
|
|
2670
|
+
private _feedControls(placeholder: string, showStatus: boolean): HtmlNode {
|
|
2671
|
+
return (
|
|
2672
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
2673
|
+
<div class="relative flex-1 min-w-[200px]">
|
|
2674
|
+
<svg
|
|
2675
|
+
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
2676
|
+
viewBox="0 0 20 20"
|
|
2677
|
+
fill="none"
|
|
2678
|
+
stroke="currentColor"
|
|
2679
|
+
stroke-width="1.7"
|
|
2680
|
+
>
|
|
2681
|
+
<circle cx="9" cy="9" r="6" />
|
|
2682
|
+
<path d="M14 14l3 3" stroke-linecap="round" />
|
|
2683
|
+
</svg>
|
|
2684
|
+
<input
|
|
2685
|
+
value={this.feedQ}
|
|
2686
|
+
live
|
|
2687
|
+
type="text"
|
|
2688
|
+
placeholder={placeholder}
|
|
2689
|
+
class="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50"
|
|
2690
|
+
/>
|
|
2691
|
+
</div>
|
|
2692
|
+
{showStatus ? (
|
|
2693
|
+
<div class="flex items-center p-0.5 rounded-lg bg-muted border border-border">
|
|
2694
|
+
{[
|
|
2695
|
+
["all", "All"],
|
|
2696
|
+
["ok", "OK"],
|
|
2697
|
+
["failed", "Failed"],
|
|
2698
|
+
].map((f) => (
|
|
2699
|
+
<button
|
|
2700
|
+
key={f[0]}
|
|
2701
|
+
onClick={() => this.setFeedStatus(f[0]!)}
|
|
2702
|
+
class={[
|
|
2703
|
+
"px-2.5 py-1 rounded-md text-xs font-semibold transition-colors",
|
|
2704
|
+
this.feedStatus === f[0]
|
|
2705
|
+
? "bg-card text-foreground shadow-sm"
|
|
2706
|
+
: "text-muted-foreground hover:text-foreground",
|
|
2707
|
+
]}
|
|
2708
|
+
>
|
|
2709
|
+
{f[1]}
|
|
2710
|
+
</button>
|
|
2711
|
+
))}
|
|
2712
|
+
</div>
|
|
2713
|
+
) : (
|
|
2714
|
+
""
|
|
2715
|
+
)}
|
|
2716
|
+
</div>
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
/** The pagination footer for a secondary feed; renders nothing when it fits one page. */
|
|
2721
|
+
private _feedFooter(total: number): HtmlNode {
|
|
2722
|
+
if (total <= FEED_PER_PAGE) return <span />;
|
|
2723
|
+
const { page, totalPages, start } = this._feedSlice(total);
|
|
2724
|
+
return (
|
|
2725
|
+
<div class="px-5 py-2.5 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
|
|
2726
|
+
<span class="tabular">
|
|
2727
|
+
{start + 1}–{Math.min(start + FEED_PER_PAGE, total)} of {total}
|
|
2728
|
+
</span>
|
|
2729
|
+
<div class="flex items-center gap-1">
|
|
2730
|
+
<button
|
|
2731
|
+
onClick={this.prevFeedPage}
|
|
2732
|
+
class={[
|
|
2733
|
+
"px-2.5 py-1 rounded-md border text-xs font-semibold transition-colors",
|
|
2734
|
+
page === 0
|
|
2735
|
+
? "border-border text-muted-foreground cursor-not-allowed"
|
|
2736
|
+
: "border-border text-muted-foreground hover:border-primary/40 hover:text-primary",
|
|
2737
|
+
]}
|
|
2738
|
+
>
|
|
2739
|
+
Prev
|
|
2740
|
+
</button>
|
|
2741
|
+
<span class="px-2 tabular text-muted-foreground">
|
|
2742
|
+
{page + 1} / {totalPages}
|
|
2743
|
+
</span>
|
|
2744
|
+
<button
|
|
2745
|
+
onClick={this.nextFeedPage}
|
|
2746
|
+
class={[
|
|
2747
|
+
"px-2.5 py-1 rounded-md border text-xs font-semibold transition-colors",
|
|
2748
|
+
page >= totalPages - 1
|
|
2749
|
+
? "border-border text-muted-foreground cursor-not-allowed"
|
|
2750
|
+
: "border-border text-muted-foreground hover:border-primary/40 hover:text-primary",
|
|
2751
|
+
]}
|
|
2752
|
+
>
|
|
2753
|
+
Next
|
|
2754
|
+
</button>
|
|
2755
|
+
</div>
|
|
2756
|
+
</div>
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// ── Notifications (distinct from Mail — one row per channel delivery) ────────
|
|
2761
|
+
|
|
2762
|
+
/** Tailwind classes tinting a notification-channel chip. */
|
|
2763
|
+
private _channelTone(channel: string): string {
|
|
2764
|
+
return channel === "mail"
|
|
2765
|
+
? "bg-primary/10 text-primary"
|
|
2766
|
+
: channel === "database"
|
|
2767
|
+
? "bg-primary/10 text-primary"
|
|
2768
|
+
: channel === "slack"
|
|
2769
|
+
? "bg-success/10 text-success"
|
|
2770
|
+
: channel === "sms"
|
|
2771
|
+
? "bg-warning/10 text-warning"
|
|
2772
|
+
: "bg-muted text-muted-foreground";
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
private renderNotifications(): HtmlNode {
|
|
2776
|
+
const all = this._filteredNotifications();
|
|
2777
|
+
const { start, end } = this._feedSlice(all.length);
|
|
2778
|
+
const rows = all.slice(start, end);
|
|
2779
|
+
return (
|
|
2780
|
+
<section class="space-y-4">
|
|
2781
|
+
{this._feedControls("Filter by notification, recipient, or channel…", true)}
|
|
2782
|
+
<Card class="overflow-hidden">
|
|
2783
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
2784
|
+
<span class="font-semibold text-foreground text-sm">
|
|
2785
|
+
Notifications ·{" "}
|
|
2786
|
+
<span class="text-muted-foreground font-normal">{all.length} shown</span>
|
|
2787
|
+
</span>
|
|
2788
|
+
<span class="text-xs text-muted-foreground">one row per channel delivery</span>
|
|
2789
|
+
</div>
|
|
2790
|
+
<div class="overflow-x-auto">
|
|
2791
|
+
<table class="w-full text-sm">
|
|
2792
|
+
<thead class="text-left text-[11px] uppercase tracking-wide text-muted-foreground bg-muted">
|
|
2793
|
+
<tr>
|
|
2794
|
+
<th class="px-5 py-2 font-medium">Notification</th>
|
|
2795
|
+
<th class="px-3 py-2 font-medium">Channel</th>
|
|
2796
|
+
<th class="px-3 py-2 font-medium">Recipient</th>
|
|
2797
|
+
<th class="px-3 py-2 font-medium">Status</th>
|
|
2798
|
+
<th class="px-3 py-2 font-medium">Time</th>
|
|
2799
|
+
<th class="px-3 py-2 font-medium">When</th>
|
|
2800
|
+
</tr>
|
|
2801
|
+
</thead>
|
|
2802
|
+
<tbody class="divide-y divide-border">
|
|
2803
|
+
{rows.length === 0 ? (
|
|
2804
|
+
<tr>
|
|
2805
|
+
<td colspan="6" class="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
2806
|
+
{this.snap.notifications.length === 0
|
|
2807
|
+
? "no notifications sent in this range"
|
|
2808
|
+
: "no notifications match the filter"}
|
|
2809
|
+
</td>
|
|
2810
|
+
</tr>
|
|
2811
|
+
) : (
|
|
2812
|
+
rows.map((n, i) => (
|
|
2813
|
+
<tr key={i} class="hover:bg-accent/60">
|
|
2814
|
+
<td class="px-5 py-2.5 font-mono text-xs text-foreground truncate max-w-[220px]">
|
|
2815
|
+
{n.notification}
|
|
2816
|
+
</td>
|
|
2817
|
+
<td class="px-3 py-2.5">
|
|
2818
|
+
<span
|
|
2819
|
+
class={[
|
|
2820
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded capitalize",
|
|
2821
|
+
this._channelTone(n.channel),
|
|
2822
|
+
]}
|
|
2823
|
+
>
|
|
2824
|
+
{n.channel}
|
|
2825
|
+
</span>
|
|
2826
|
+
</td>
|
|
2827
|
+
<td class="px-3 py-2.5 text-xs text-muted-foreground truncate max-w-[180px]">
|
|
2828
|
+
{n.recipient}
|
|
2829
|
+
</td>
|
|
2830
|
+
<td class="px-3 py-2.5">
|
|
2831
|
+
<span
|
|
2832
|
+
class={[
|
|
2833
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded-full uppercase",
|
|
2834
|
+
n.status === "ok"
|
|
2835
|
+
? "bg-success/10 text-success"
|
|
2836
|
+
: "bg-destructive/10 text-destructive",
|
|
2837
|
+
]}
|
|
2838
|
+
>
|
|
2839
|
+
{n.status === "ok" ? "sent" : "failed"}
|
|
2840
|
+
</span>
|
|
2841
|
+
</td>
|
|
2842
|
+
<td class="px-3 py-2.5 tabular text-xs text-muted-foreground">{n.ms}ms</td>
|
|
2843
|
+
<td class="px-3 py-2.5 text-[11px] text-muted-foreground tabular whitespace-nowrap">
|
|
2844
|
+
{n.when}
|
|
2845
|
+
</td>
|
|
2846
|
+
</tr>
|
|
2847
|
+
))
|
|
2848
|
+
)}
|
|
2849
|
+
</tbody>
|
|
2850
|
+
</table>
|
|
2851
|
+
</div>
|
|
2852
|
+
{this._feedFooter(all.length)}
|
|
2853
|
+
</Card>
|
|
2854
|
+
</section>
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// ── Commands (console / Artisan runs) ────────────────────────────────────────
|
|
2859
|
+
|
|
2860
|
+
private renderCommands(): HtmlNode {
|
|
2861
|
+
const all = this._filteredCommands();
|
|
2862
|
+
const { start, end } = this._feedSlice(all.length);
|
|
2863
|
+
const rows = all.slice(start, end);
|
|
2864
|
+
return (
|
|
2865
|
+
<section class="space-y-4">
|
|
2866
|
+
{this._feedControls("Filter by command name…", true)}
|
|
2867
|
+
<Card class="overflow-hidden">
|
|
2868
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
2869
|
+
<span class="font-semibold text-foreground text-sm">
|
|
2870
|
+
Command runs · <span class="text-muted-foreground font-normal">{all.length}</span>
|
|
2871
|
+
</span>
|
|
2872
|
+
<span class="text-xs text-muted-foreground">CLI + in-process Artisan calls</span>
|
|
2873
|
+
</div>
|
|
2874
|
+
<div class="divide-y divide-border">
|
|
2875
|
+
{rows.length === 0 ? (
|
|
2876
|
+
<div class="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
2877
|
+
{this.snap.commands.length === 0
|
|
2878
|
+
? "no commands run in this range"
|
|
2879
|
+
: "no commands match the filter"}
|
|
2880
|
+
</div>
|
|
2881
|
+
) : (
|
|
2882
|
+
rows.map((c, i) => (
|
|
2883
|
+
<div key={i} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
2884
|
+
<span
|
|
2885
|
+
class={[
|
|
2886
|
+
"w-2 h-2 rounded-full shrink-0",
|
|
2887
|
+
c.status === "ok" ? "bg-success" : "bg-destructive",
|
|
2888
|
+
]}
|
|
2889
|
+
/>
|
|
2890
|
+
<span class="font-mono text-xs text-foreground truncate flex-1">{c.name}</span>
|
|
2891
|
+
<span
|
|
2892
|
+
class={[
|
|
2893
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded-full uppercase shrink-0",
|
|
2894
|
+
c.status === "ok"
|
|
2895
|
+
? "bg-success/10 text-success"
|
|
2896
|
+
: "bg-destructive/10 text-destructive",
|
|
2897
|
+
]}
|
|
2898
|
+
>
|
|
2899
|
+
exit {c.code}
|
|
2900
|
+
</span>
|
|
2901
|
+
<span class="tabular text-xs font-semibold text-muted-foreground shrink-0 w-16 text-right">
|
|
2902
|
+
{c.ms}ms
|
|
2903
|
+
</span>
|
|
2904
|
+
<span class="text-[11px] text-muted-foreground tabular shrink-0 w-16 text-right whitespace-nowrap">
|
|
2905
|
+
{c.when}
|
|
2906
|
+
</span>
|
|
2907
|
+
</div>
|
|
2908
|
+
))
|
|
2909
|
+
)}
|
|
2910
|
+
</div>
|
|
2911
|
+
{this._feedFooter(all.length)}
|
|
2912
|
+
</Card>
|
|
2913
|
+
</section>
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// ── System / health ─────────────────────────────────────────────────────────
|
|
2918
|
+
|
|
2919
|
+
private renderSystem(): HtmlNode {
|
|
2920
|
+
const s = this.snap;
|
|
2921
|
+
return (
|
|
2922
|
+
<section class="space-y-6">
|
|
2923
|
+
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
2924
|
+
{s.health.map((h) => (
|
|
2925
|
+
<div
|
|
2926
|
+
key={h.name}
|
|
2927
|
+
class="rounded-xl bg-card border border-border p-4 shadow-sm flex items-center gap-3"
|
|
2928
|
+
>
|
|
2929
|
+
<span
|
|
2930
|
+
class={[
|
|
2931
|
+
"grid place-items-center w-9 h-9 rounded-lg shrink-0",
|
|
2932
|
+
h.ok ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive",
|
|
2933
|
+
]}
|
|
2934
|
+
>
|
|
2935
|
+
{h.ok ? (
|
|
2936
|
+
<svg
|
|
2937
|
+
viewBox="0 0 20 20"
|
|
2938
|
+
fill="none"
|
|
2939
|
+
stroke="currentColor"
|
|
2940
|
+
stroke-width="2"
|
|
2941
|
+
class="w-4 h-4"
|
|
2942
|
+
stroke-linecap="round"
|
|
2943
|
+
stroke-linejoin="round"
|
|
2944
|
+
>
|
|
2945
|
+
<path d="M4 10.5l4 4 8-9" />
|
|
2946
|
+
</svg>
|
|
2947
|
+
) : (
|
|
2948
|
+
<svg
|
|
2949
|
+
viewBox="0 0 20 20"
|
|
2950
|
+
fill="none"
|
|
2951
|
+
stroke="currentColor"
|
|
2952
|
+
stroke-width="2"
|
|
2953
|
+
class="w-4 h-4"
|
|
2954
|
+
stroke-linecap="round"
|
|
2955
|
+
stroke-linejoin="round"
|
|
2956
|
+
>
|
|
2957
|
+
<path d="M5 5l10 10M15 5L5 15" />
|
|
2958
|
+
</svg>
|
|
2959
|
+
)}
|
|
2960
|
+
</span>
|
|
2961
|
+
<div class="min-w-0">
|
|
2962
|
+
<div class="text-sm font-semibold text-foreground">{h.name}</div>
|
|
2963
|
+
<div class="text-[11px] text-muted-foreground tabular">{h.detail}</div>
|
|
2964
|
+
</div>
|
|
2965
|
+
</div>
|
|
2966
|
+
))}
|
|
2967
|
+
</div>
|
|
2968
|
+
|
|
2969
|
+
<div class="grid lg:grid-cols-3 gap-4">
|
|
2970
|
+
{s.gauges.map((g) => (
|
|
2971
|
+
<div key={g.label} class="rounded-xl bg-card border border-border p-5 shadow-sm">
|
|
2972
|
+
<div class="flex items-center justify-between mb-2">
|
|
2973
|
+
<span class="text-sm font-medium text-muted-foreground">{g.label}</span>
|
|
2974
|
+
<span class={["tabular font-bold", gaugeText(g.value)]}>{g.value}%</span>
|
|
2975
|
+
</div>
|
|
2976
|
+
<div class="h-2.5 rounded-full bg-muted overflow-hidden">
|
|
2977
|
+
<div
|
|
2978
|
+
class={["h-full transition-all", gaugeBar(g.value)]}
|
|
2979
|
+
style={`width:${g.value}%`}
|
|
2980
|
+
/>
|
|
2981
|
+
</div>
|
|
2982
|
+
<div class="mt-1.5 text-[11px] text-muted-foreground">{g.sub}</div>
|
|
2983
|
+
</div>
|
|
2984
|
+
))}
|
|
2985
|
+
</div>
|
|
2986
|
+
|
|
2987
|
+
<div class="grid lg:grid-cols-2 gap-6">
|
|
2988
|
+
<Card class="overflow-hidden">
|
|
2989
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
2990
|
+
Uptime checks
|
|
2991
|
+
</div>
|
|
2992
|
+
<div class="divide-y divide-border">
|
|
2993
|
+
{s.uptimeChecks.map((u) => (
|
|
2994
|
+
<div key={u.name} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
2995
|
+
<span
|
|
2996
|
+
class={[
|
|
2997
|
+
"w-2 h-2 rounded-full shrink-0",
|
|
2998
|
+
u.ok ? "bg-success" : "bg-destructive",
|
|
2999
|
+
]}
|
|
3000
|
+
/>
|
|
3001
|
+
<span class="font-mono text-xs text-foreground truncate">{u.name}</span>
|
|
3002
|
+
<span
|
|
3003
|
+
class={[
|
|
3004
|
+
"ml-auto tabular text-xs",
|
|
3005
|
+
u.ok ? "text-muted-foreground" : "text-destructive font-semibold",
|
|
3006
|
+
]}
|
|
3007
|
+
>
|
|
3008
|
+
{u.ok ? `${u.ms}ms` : "down"}
|
|
3009
|
+
</span>
|
|
3010
|
+
<span class="tabular text-[11px] text-muted-foreground w-14 text-right">
|
|
3011
|
+
{u.uptime}
|
|
3012
|
+
</span>
|
|
3013
|
+
</div>
|
|
3014
|
+
))}
|
|
3015
|
+
</div>
|
|
3016
|
+
</Card>
|
|
3017
|
+
<Card class="overflow-hidden">
|
|
3018
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
3019
|
+
Scheduled task check-ins
|
|
3020
|
+
</div>
|
|
3021
|
+
<div class="divide-y divide-border">
|
|
3022
|
+
{s.checkins.map((c) => (
|
|
3023
|
+
<div key={c.name} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
3024
|
+
<span
|
|
3025
|
+
class={[
|
|
3026
|
+
"text-[9px] font-bold px-1.5 py-0.5 rounded-full uppercase shrink-0",
|
|
3027
|
+
c.status === "ok"
|
|
3028
|
+
? "bg-success/10 text-success"
|
|
3029
|
+
: c.status === "late"
|
|
3030
|
+
? "bg-warning/10 text-warning"
|
|
3031
|
+
: "bg-destructive/10 text-destructive",
|
|
3032
|
+
]}
|
|
3033
|
+
>
|
|
3034
|
+
{c.status}
|
|
3035
|
+
</span>
|
|
3036
|
+
<span class="font-mono text-xs text-foreground truncate">{c.name}</span>
|
|
3037
|
+
<span class="font-mono text-[11px] text-muted-foreground hidden sm:inline">
|
|
3038
|
+
{c.cron}
|
|
3039
|
+
</span>
|
|
3040
|
+
<span class="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
|
3041
|
+
ran {c.last}
|
|
3042
|
+
</span>
|
|
3043
|
+
</div>
|
|
3044
|
+
))}
|
|
3045
|
+
</div>
|
|
3046
|
+
</Card>
|
|
3047
|
+
</div>
|
|
3048
|
+
|
|
3049
|
+
<Card class="overflow-hidden">
|
|
3050
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
3051
|
+
<span class="font-semibold text-foreground text-sm">Scheduled task runs</span>
|
|
3052
|
+
<span class="text-xs text-muted-foreground">history · ok / failed / skipped</span>
|
|
3053
|
+
</div>
|
|
3054
|
+
<div class="divide-y divide-border max-h-72 overflow-y-auto">
|
|
3055
|
+
{s.scheduledRuns.length === 0 ? (
|
|
3056
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
3057
|
+
no task runs in this range
|
|
3058
|
+
</div>
|
|
3059
|
+
) : (
|
|
3060
|
+
s.scheduledRuns.map((e, i) => this._feedRow(e, i))
|
|
3061
|
+
)}
|
|
3062
|
+
</div>
|
|
3063
|
+
</Card>
|
|
3064
|
+
|
|
3065
|
+
{this.renderStoragePanel()}
|
|
3066
|
+
|
|
3067
|
+
<Card class="p-5 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm">
|
|
3068
|
+
<div>
|
|
3069
|
+
<span class="text-muted-foreground text-xs">Uptime</span>
|
|
3070
|
+
<div class="font-semibold text-foreground tabular">{s.meta.uptime}</div>
|
|
3071
|
+
</div>
|
|
3072
|
+
<div>
|
|
3073
|
+
<span class="text-muted-foreground text-xs">Bun</span>
|
|
3074
|
+
<div class="font-semibold text-foreground font-mono">{s.meta.bun}</div>
|
|
3075
|
+
</div>
|
|
3076
|
+
<div>
|
|
3077
|
+
<span class="text-muted-foreground text-xs">Zerotal</span>
|
|
3078
|
+
<div class="font-semibold text-foreground font-mono">{s.meta.zerotal}</div>
|
|
3079
|
+
</div>
|
|
3080
|
+
<div>
|
|
3081
|
+
<span class="text-muted-foreground text-xs">Region</span>
|
|
3082
|
+
<div class="font-semibold text-foreground">{s.meta.region}</div>
|
|
3083
|
+
</div>
|
|
3084
|
+
<div>
|
|
3085
|
+
<span class="text-muted-foreground text-xs">Deploy</span>
|
|
3086
|
+
<div class="font-semibold text-foreground font-mono">{s.meta.deploy}</div>
|
|
3087
|
+
</div>
|
|
3088
|
+
</Card>
|
|
3089
|
+
</section>
|
|
3090
|
+
);
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
/** Storage & retention panel: persisted-row counts plus cleanup controls. */
|
|
3094
|
+
private renderStoragePanel(): HtmlNode {
|
|
3095
|
+
const st = this.snap.storage;
|
|
3096
|
+
const cfg = this._config();
|
|
3097
|
+
const days = cfg?.retentionDays ?? 7;
|
|
3098
|
+
const mode = cfg?.retentionMode ?? "delete";
|
|
3099
|
+
const cards: Array<[string, number]> = [
|
|
3100
|
+
["Requests", st.requests],
|
|
3101
|
+
["Queries", st.queries],
|
|
3102
|
+
["Exceptions", st.exceptions],
|
|
3103
|
+
["HTTP calls", st.httpCalls],
|
|
3104
|
+
["Cache ops", st.cacheEvents],
|
|
3105
|
+
["Mail", st.mail],
|
|
3106
|
+
["Jobs", st.jobs],
|
|
3107
|
+
["Archived", st.archived],
|
|
3108
|
+
];
|
|
3109
|
+
return (
|
|
3110
|
+
<Card class="overflow-hidden">
|
|
3111
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between gap-3">
|
|
3112
|
+
<div class="font-semibold text-foreground text-sm">Storage & retention</div>
|
|
3113
|
+
<div class="text-[11px] text-muted-foreground">
|
|
3114
|
+
keeping <span class="font-semibold text-muted-foreground">{days}d</span> · then {mode}
|
|
3115
|
+
</div>
|
|
3116
|
+
</div>
|
|
3117
|
+
<div class="p-5 space-y-4">
|
|
3118
|
+
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
3119
|
+
{cards.map(([label, value]) => (
|
|
3120
|
+
<div key={label} class="rounded-lg bg-muted border border-border px-3 py-2">
|
|
3121
|
+
<div class="text-[11px] text-muted-foreground">{label}</div>
|
|
3122
|
+
<div class="tabular font-semibold text-foreground">{commas(value)}</div>
|
|
3123
|
+
</div>
|
|
3124
|
+
))}
|
|
3125
|
+
</div>
|
|
3126
|
+
<div class="flex items-center justify-between flex-wrap gap-3">
|
|
3127
|
+
<div class="text-[11px] text-muted-foreground">
|
|
3128
|
+
{st.oldestMs ? `oldest record ${ago(st.oldestMs)}` : "no data recorded yet"}
|
|
3129
|
+
</div>
|
|
3130
|
+
<div class="flex items-center gap-2">
|
|
3131
|
+
<a
|
|
3132
|
+
href={`${this.basePath.replace(/\/$/, "")}/export.json?range=${this.range}`}
|
|
3133
|
+
download
|
|
3134
|
+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold border border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-primary transition-colors"
|
|
3135
|
+
>
|
|
3136
|
+
Export JSON
|
|
3137
|
+
</a>
|
|
3138
|
+
<button
|
|
3139
|
+
onClick={this.cleanupData}
|
|
3140
|
+
loadingAttr="disabled"
|
|
3141
|
+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold border border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-primary transition-colors"
|
|
3142
|
+
>
|
|
3143
|
+
Clean up now
|
|
3144
|
+
</button>
|
|
3145
|
+
<button
|
|
3146
|
+
onClick={this.clearData}
|
|
3147
|
+
confirm="Permanently delete ALL recorded monitor data? This cannot be undone."
|
|
3148
|
+
loadingAttr="disabled"
|
|
3149
|
+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
|
|
3150
|
+
>
|
|
3151
|
+
Clear all
|
|
3152
|
+
</button>
|
|
3153
|
+
</div>
|
|
3154
|
+
</div>
|
|
3155
|
+
</div>
|
|
3156
|
+
</Card>
|
|
3157
|
+
);
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
// ── Security / audit ─────────────────────────────────────────────────────────
|
|
3161
|
+
|
|
3162
|
+
private _statusDot(status: FeedEvent["status"]): string {
|
|
3163
|
+
return status === "bad"
|
|
3164
|
+
? "bg-destructive"
|
|
3165
|
+
: status === "warn"
|
|
3166
|
+
? "bg-warning"
|
|
3167
|
+
: status === "ok"
|
|
3168
|
+
? "bg-success"
|
|
3169
|
+
: "bg-muted-foreground/40";
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
private _feedRow(e: FeedEvent, i: number): HtmlNode {
|
|
3173
|
+
return (
|
|
3174
|
+
<div key={i} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
3175
|
+
<span class={["w-2 h-2 rounded-full shrink-0", this._statusDot(e.status)]} />
|
|
3176
|
+
<span class="font-mono text-xs text-foreground whitespace-nowrap">{e.label}</span>
|
|
3177
|
+
{e.route ? (
|
|
3178
|
+
<span class="text-[11px] text-muted-foreground font-mono truncate">{e.route}</span>
|
|
3179
|
+
) : (
|
|
3180
|
+
""
|
|
3181
|
+
)}
|
|
3182
|
+
{e.detail ? (
|
|
3183
|
+
<span class="text-[11px] text-muted-foreground truncate flex-1">{e.detail}</span>
|
|
3184
|
+
) : (
|
|
3185
|
+
<span class="flex-1" />
|
|
3186
|
+
)}
|
|
3187
|
+
<span class="text-[11px] text-muted-foreground whitespace-nowrap shrink-0">{e.when}</span>
|
|
3188
|
+
</div>
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
private renderSecurity(): HtmlNode {
|
|
3193
|
+
const all = this._filteredSecurity();
|
|
3194
|
+
const { start, end } = this._feedSlice(all.length);
|
|
3195
|
+
const rows = all.slice(start, end);
|
|
3196
|
+
return (
|
|
3197
|
+
<section class="space-y-4">
|
|
3198
|
+
{this._feedControls("Filter by event, user, or guard…", false)}
|
|
3199
|
+
<Card class="overflow-hidden">
|
|
3200
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
3201
|
+
<span class="font-semibold text-foreground text-sm">
|
|
3202
|
+
Security & audit ·{" "}
|
|
3203
|
+
<span class="text-muted-foreground font-normal">{all.length} shown</span>
|
|
3204
|
+
</span>
|
|
3205
|
+
<span class="text-xs text-muted-foreground">logins · logouts · denials · tokens</span>
|
|
3206
|
+
</div>
|
|
3207
|
+
<div class="divide-y divide-border">
|
|
3208
|
+
{rows.length === 0 ? (
|
|
3209
|
+
<div class="px-5 py-12 text-center text-sm text-muted-foreground">
|
|
3210
|
+
{this.snap.security.length === 0
|
|
3211
|
+
? "no security events in this range"
|
|
3212
|
+
: "no events match the filter"}
|
|
3213
|
+
</div>
|
|
3214
|
+
) : (
|
|
3215
|
+
rows.map((e, i) => this._feedRow(e, i))
|
|
3216
|
+
)}
|
|
3217
|
+
</div>
|
|
3218
|
+
{this._feedFooter(all.length)}
|
|
3219
|
+
</Card>
|
|
3220
|
+
</section>
|
|
3221
|
+
);
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
// ── Alerts (threshold-alert history) ─────────────────────────────────────────
|
|
3225
|
+
|
|
3226
|
+
private renderAlertsTab(): HtmlNode {
|
|
3227
|
+
const alerts = this.snap.alertHistory;
|
|
3228
|
+
return (
|
|
3229
|
+
<section class="space-y-3">
|
|
3230
|
+
<div class="flex items-center justify-between px-1 text-xs text-muted-foreground">
|
|
3231
|
+
<span>
|
|
3232
|
+
{alerts.length} alert {alerts.length === 1 ? "kind" : "kinds"} · grouped · click to
|
|
3233
|
+
expand
|
|
3234
|
+
</span>
|
|
3235
|
+
<span class="hidden sm:inline">error rate · p95 · queue backlog · rollbacks</span>
|
|
3236
|
+
</div>
|
|
3237
|
+
{alerts.length === 0 ? (
|
|
3238
|
+
<Card class="px-5 py-16 text-center text-sm text-muted-foreground">
|
|
3239
|
+
no alerts fired in this range — all thresholds nominal 🎉
|
|
3240
|
+
</Card>
|
|
3241
|
+
) : (
|
|
3242
|
+
alerts.map((a) => {
|
|
3243
|
+
const open = this.openAlert === a.id;
|
|
3244
|
+
const critical = a.level === "critical";
|
|
3245
|
+
return (
|
|
3246
|
+
<div
|
|
3247
|
+
key={a.id}
|
|
3248
|
+
class="rounded-xl bg-card border border-border shadow-sm overflow-hidden"
|
|
3249
|
+
>
|
|
3250
|
+
<div
|
|
3251
|
+
class="flex items-center gap-3 px-5 py-3 cursor-pointer hover:bg-accent/60"
|
|
3252
|
+
onClick={() => this.toggleAlert(a.id)}
|
|
3253
|
+
>
|
|
3254
|
+
<span
|
|
3255
|
+
class={[
|
|
3256
|
+
"w-2 h-2 rounded-full shrink-0",
|
|
3257
|
+
critical ? "bg-destructive" : "bg-warning",
|
|
3258
|
+
]}
|
|
3259
|
+
/>
|
|
3260
|
+
<span class="font-semibold text-foreground text-sm shrink-0">{a.title}</span>
|
|
3261
|
+
<span
|
|
3262
|
+
class={[
|
|
3263
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded-full uppercase shrink-0",
|
|
3264
|
+
critical
|
|
3265
|
+
? "bg-destructive/10 text-destructive"
|
|
3266
|
+
: "bg-warning/10 text-warning",
|
|
3267
|
+
]}
|
|
3268
|
+
>
|
|
3269
|
+
{a.level}
|
|
3270
|
+
</span>
|
|
3271
|
+
{a.count > 1 ? (
|
|
3272
|
+
<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground shrink-0">
|
|
3273
|
+
×{a.count}
|
|
3274
|
+
</span>
|
|
3275
|
+
) : (
|
|
3276
|
+
""
|
|
3277
|
+
)}
|
|
3278
|
+
<span class="text-xs text-muted-foreground truncate hidden md:inline">
|
|
3279
|
+
{a.detail}
|
|
3280
|
+
</span>
|
|
3281
|
+
<span class="ml-auto text-[11px] text-muted-foreground whitespace-nowrap shrink-0">
|
|
3282
|
+
last {a.lastSeen}
|
|
3283
|
+
</span>
|
|
3284
|
+
<span class="text-muted-foreground shrink-0 text-[10px]">{open ? "▲" : "▼"}</span>
|
|
3285
|
+
</div>
|
|
3286
|
+
{open ? this._renderAlertDetail(a) : ""}
|
|
3287
|
+
</div>
|
|
3288
|
+
);
|
|
3289
|
+
})
|
|
3290
|
+
)}
|
|
3291
|
+
</section>
|
|
3292
|
+
);
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
/** Expanded alert: the breach headline, the captured state, and the firing timeline. */
|
|
3296
|
+
private _renderAlertDetail(a: AlertEntry): HtmlNode {
|
|
3297
|
+
const c = a.context;
|
|
3298
|
+
const fmt = (v: number, unit: string): string =>
|
|
3299
|
+
unit === "ms" ? `${v}ms` : unit === "%" ? `${v}%` : commas(v);
|
|
3300
|
+
const stats: Array<[string, string]> = [
|
|
3301
|
+
["Error rate", `${c.errorRate}%`],
|
|
3302
|
+
["p50 / p95 / p99", `${c.p50} / ${c.p95} / ${c.p99}ms`],
|
|
3303
|
+
["Requests / min", commas(c.rpm)],
|
|
3304
|
+
["Apdex", c.apdex.toFixed(2)],
|
|
3305
|
+
["Pending jobs", commas(c.pending)],
|
|
3306
|
+
["Rollbacks", commas(c.rolledBack)],
|
|
3307
|
+
];
|
|
3308
|
+
return (
|
|
3309
|
+
<div class="border-t border-border bg-muted/50 px-5 py-4 space-y-4">
|
|
3310
|
+
<div class="flex items-baseline gap-2 flex-wrap">
|
|
3311
|
+
<span class="text-xs text-muted-foreground">{a.metric}</span>
|
|
3312
|
+
<span
|
|
3313
|
+
class={[
|
|
3314
|
+
"text-2xl font-bold tabular font-mono",
|
|
3315
|
+
a.level === "critical" ? "text-destructive" : "text-warning",
|
|
3316
|
+
]}
|
|
3317
|
+
>
|
|
3318
|
+
{fmt(a.value, a.unit)}
|
|
3319
|
+
</span>
|
|
3320
|
+
<span class="text-[11px] text-muted-foreground">
|
|
3321
|
+
vs threshold {fmt(a.threshold, a.unit)}
|
|
3322
|
+
</span>
|
|
3323
|
+
<span class="text-[11px] text-muted-foreground ml-auto">
|
|
3324
|
+
first {a.firstSeen} · last {a.lastSeen}
|
|
3325
|
+
</span>
|
|
3326
|
+
</div>
|
|
3327
|
+
|
|
3328
|
+
<div>
|
|
3329
|
+
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
3330
|
+
State when it fired
|
|
3331
|
+
</div>
|
|
3332
|
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
3333
|
+
{stats.map(([label, value]) => (
|
|
3334
|
+
<div key={label} class="rounded-lg bg-card border border-border px-3 py-2">
|
|
3335
|
+
<div class="text-[11px] text-muted-foreground">{label}</div>
|
|
3336
|
+
<div class="tabular font-semibold text-foreground text-sm">{value}</div>
|
|
3337
|
+
</div>
|
|
3338
|
+
))}
|
|
3339
|
+
</div>
|
|
3340
|
+
</div>
|
|
3341
|
+
|
|
3342
|
+
{c.slowRoutes.length > 0 || c.topException ? (
|
|
3343
|
+
<div class="grid sm:grid-cols-2 gap-3">
|
|
3344
|
+
{c.slowRoutes.length > 0 ? (
|
|
3345
|
+
<div class="rounded-lg bg-card border border-border overflow-hidden">
|
|
3346
|
+
<div class="px-3 py-2 text-[11px] font-semibold text-muted-foreground border-b border-border">
|
|
3347
|
+
Slowest routes then
|
|
3348
|
+
</div>
|
|
3349
|
+
<div class="divide-y divide-border">
|
|
3350
|
+
{c.slowRoutes.map((r) => (
|
|
3351
|
+
<div
|
|
3352
|
+
key={r.path + r.method}
|
|
3353
|
+
class="flex items-center gap-2 px-3 py-1.5 text-xs"
|
|
3354
|
+
>
|
|
3355
|
+
<span
|
|
3356
|
+
class={[
|
|
3357
|
+
"text-[9px] font-bold px-1 py-0.5 rounded shrink-0",
|
|
3358
|
+
methodTone(r.method),
|
|
3359
|
+
]}
|
|
3360
|
+
>
|
|
3361
|
+
{r.method}
|
|
3362
|
+
</span>
|
|
3363
|
+
<span class="font-mono text-muted-foreground truncate flex-1">{r.path}</span>
|
|
3364
|
+
<span class="tabular font-semibold text-muted-foreground shrink-0">
|
|
3365
|
+
{r.ms}ms
|
|
3366
|
+
</span>
|
|
3367
|
+
</div>
|
|
3368
|
+
))}
|
|
3369
|
+
</div>
|
|
3370
|
+
</div>
|
|
3371
|
+
) : (
|
|
3372
|
+
""
|
|
3373
|
+
)}
|
|
3374
|
+
{c.topException ? (
|
|
3375
|
+
<div class="rounded-lg bg-card border border-border px-3 py-2.5 flex items-center gap-2">
|
|
3376
|
+
<span class="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
|
3377
|
+
<span class="text-[11px] text-muted-foreground">top exception</span>
|
|
3378
|
+
<span class="font-mono text-xs text-foreground truncate">{c.topException}</span>
|
|
3379
|
+
</div>
|
|
3380
|
+
) : (
|
|
3381
|
+
""
|
|
3382
|
+
)}
|
|
3383
|
+
</div>
|
|
3384
|
+
) : (
|
|
3385
|
+
""
|
|
3386
|
+
)}
|
|
3387
|
+
|
|
3388
|
+
{a.count > 1 ? (
|
|
3389
|
+
<div>
|
|
3390
|
+
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
3391
|
+
Recent firings ({a.count})
|
|
3392
|
+
</div>
|
|
3393
|
+
<div class="rounded-lg bg-card border border-border divide-y divide-border max-h-44 overflow-y-auto">
|
|
3394
|
+
{a.occurrences.map((o, i) => (
|
|
3395
|
+
<div key={i} class="flex items-center gap-3 px-3 py-1.5 text-xs">
|
|
3396
|
+
<span class="w-1.5 h-1.5 rounded-full bg-muted-foreground/40 shrink-0" />
|
|
3397
|
+
<span class="text-muted-foreground truncate flex-1">{o.detail}</span>
|
|
3398
|
+
<span class="text-[11px] text-muted-foreground tabular shrink-0">{o.when}</span>
|
|
3399
|
+
</div>
|
|
3400
|
+
))}
|
|
3401
|
+
</div>
|
|
3402
|
+
</div>
|
|
3403
|
+
) : (
|
|
3404
|
+
""
|
|
3405
|
+
)}
|
|
3406
|
+
</div>
|
|
3407
|
+
);
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
// ── Logs ─────────────────────────────────────────────────────────────────────
|
|
3411
|
+
|
|
3412
|
+
private _logTone(level: string): string {
|
|
3413
|
+
return level === "error" || level === "fatal"
|
|
3414
|
+
? "text-destructive"
|
|
3415
|
+
: level === "warn"
|
|
3416
|
+
? "text-warning"
|
|
3417
|
+
: level === "debug"
|
|
3418
|
+
? "text-muted-foreground"
|
|
3419
|
+
: "text-success";
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
private renderLogs(): HtmlNode {
|
|
3423
|
+
const all = this.snap.logs;
|
|
3424
|
+
const lines = this.logLevel === "all" ? all : all.filter((l) => l.label === this.logLevel);
|
|
3425
|
+
return (
|
|
3426
|
+
<section class="space-y-4">
|
|
3427
|
+
<div class="flex items-center p-0.5 rounded-lg bg-muted border border-border w-fit">
|
|
3428
|
+
{["all", "debug", "info", "warn", "error"].map((lv) => (
|
|
3429
|
+
<button
|
|
3430
|
+
key={lv}
|
|
3431
|
+
onClick={() => this.setLogLevel(lv)}
|
|
3432
|
+
class={[
|
|
3433
|
+
"px-2.5 py-1 rounded-md text-xs font-semibold capitalize transition-colors",
|
|
3434
|
+
this.logLevel === lv
|
|
3435
|
+
? "bg-card text-foreground shadow-sm"
|
|
3436
|
+
: "text-muted-foreground hover:text-foreground",
|
|
3437
|
+
]}
|
|
3438
|
+
>
|
|
3439
|
+
{lv}
|
|
3440
|
+
</button>
|
|
3441
|
+
))}
|
|
3442
|
+
</div>
|
|
3443
|
+
<div class="rounded-xl bg-foreground shadow-sm overflow-hidden">
|
|
3444
|
+
<div class="px-5 py-2.5 border-b border-border flex items-center justify-between">
|
|
3445
|
+
<span class="font-semibold text-muted-foreground text-sm">Application logs</span>
|
|
3446
|
+
<span class="text-[11px] text-muted-foreground">{lines.length} lines</span>
|
|
3447
|
+
</div>
|
|
3448
|
+
<div class="divide-y divide-border max-h-[72vh] overflow-y-auto font-mono text-[11px]">
|
|
3449
|
+
{lines.length === 0 ? (
|
|
3450
|
+
<div class="px-5 py-12 text-center text-muted-foreground">no logs in this range</div>
|
|
3451
|
+
) : (
|
|
3452
|
+
lines.map((l, i) => (
|
|
3453
|
+
<div key={i} class="flex items-start gap-3 px-5 py-1.5 hover:bg-accent">
|
|
3454
|
+
<span class={["uppercase font-bold shrink-0 w-12", this._logTone(l.label)]}>
|
|
3455
|
+
{l.label}
|
|
3456
|
+
</span>
|
|
3457
|
+
<span class="text-muted-foreground flex-1 break-all">{l.detail}</span>
|
|
3458
|
+
{l.route ? (
|
|
3459
|
+
<span class="text-muted-foreground shrink-0" title="request id">
|
|
3460
|
+
{l.route.slice(0, 8)}
|
|
3461
|
+
</span>
|
|
3462
|
+
) : (
|
|
3463
|
+
""
|
|
3464
|
+
)}
|
|
3465
|
+
<span class="text-muted-foreground shrink-0 whitespace-nowrap">{l.when}</span>
|
|
3466
|
+
</div>
|
|
3467
|
+
))
|
|
3468
|
+
)}
|
|
3469
|
+
</div>
|
|
3470
|
+
</div>
|
|
3471
|
+
</section>
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// ── Realtime / WebSocket ─────────────────────────────────────────────────────
|
|
3476
|
+
|
|
3477
|
+
private renderRealtime(): HtmlNode {
|
|
3478
|
+
const r = this.snap.realtime;
|
|
3479
|
+
const actions = this._filteredWsActions();
|
|
3480
|
+
const { start, end } = this._feedSlice(actions.length);
|
|
3481
|
+
const actionRows = actions.slice(start, end);
|
|
3482
|
+
const cards: Array<[string, string, string]> = [
|
|
3483
|
+
["Active connections", commas(r.activeConnections), "live WebSocket"],
|
|
3484
|
+
["Actions / min", String(r.actionsPerMin), "round-trips"],
|
|
3485
|
+
["Avg action", `${r.avgActionMs}ms`, `${this._fmtMem(r.avgMemKb)} heap`],
|
|
3486
|
+
["Opened / closed", `${commas(r.opened)} / ${commas(r.closed)}`, "in range"],
|
|
3487
|
+
];
|
|
3488
|
+
return (
|
|
3489
|
+
<section class="space-y-6">
|
|
3490
|
+
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
3491
|
+
{cards.map(([label, value, sub]) => (
|
|
3492
|
+
<div key={label} class="rounded-xl bg-card border border-border p-5 shadow-sm">
|
|
3493
|
+
<div class="text-sm text-muted-foreground">{label}</div>
|
|
3494
|
+
<div class="text-2xl font-bold text-foreground tabular mt-1">{value}</div>
|
|
3495
|
+
<div class="text-[11px] text-muted-foreground mt-1">{sub}</div>
|
|
3496
|
+
</div>
|
|
3497
|
+
))}
|
|
3498
|
+
</div>
|
|
3499
|
+
|
|
3500
|
+
{this._feedControls("Filter by component, action, user, or IP…", true)}
|
|
3501
|
+
|
|
3502
|
+
<div class="grid lg:grid-cols-3 gap-6">
|
|
3503
|
+
{this.renderConnectedClients()}
|
|
3504
|
+
|
|
3505
|
+
<Card class="overflow-hidden">
|
|
3506
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
3507
|
+
Busiest components
|
|
3508
|
+
</div>
|
|
3509
|
+
<div class="divide-y divide-border">
|
|
3510
|
+
{r.components.length === 0 ? (
|
|
3511
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
3512
|
+
no actions yet
|
|
3513
|
+
</div>
|
|
3514
|
+
) : (
|
|
3515
|
+
r.components.map((c) => (
|
|
3516
|
+
<div key={c.name} class="flex items-center gap-3 px-5 py-2.5 text-sm">
|
|
3517
|
+
<span class="font-mono text-xs text-foreground truncate flex-1">{c.name}</span>
|
|
3518
|
+
<span class="tabular text-[11px] text-muted-foreground shrink-0">
|
|
3519
|
+
{c.avgMs}ms avg
|
|
3520
|
+
</span>
|
|
3521
|
+
<span class="tabular text-xs font-semibold text-foreground w-12 text-right shrink-0">
|
|
3522
|
+
{commas(c.actions)}
|
|
3523
|
+
</span>
|
|
3524
|
+
</div>
|
|
3525
|
+
))
|
|
3526
|
+
)}
|
|
3527
|
+
</div>
|
|
3528
|
+
</Card>
|
|
3529
|
+
|
|
3530
|
+
<Card class="overflow-hidden">
|
|
3531
|
+
<div class="px-5 py-3 border-b border-border font-semibold text-foreground text-sm">
|
|
3532
|
+
Slowest actions
|
|
3533
|
+
</div>
|
|
3534
|
+
<div class="divide-y divide-border">
|
|
3535
|
+
{r.slowActions.length === 0 ? (
|
|
3536
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
3537
|
+
no actions yet
|
|
3538
|
+
</div>
|
|
3539
|
+
) : (
|
|
3540
|
+
r.slowActions.map((a, i) => (
|
|
3541
|
+
<div key={i} class="flex items-center gap-2 px-5 py-2.5 text-sm">
|
|
3542
|
+
<span class="font-mono text-xs text-muted-foreground truncate">
|
|
3543
|
+
{a.component}
|
|
3544
|
+
</span>
|
|
3545
|
+
<span class="font-mono text-[11px] text-primary shrink-0">{a.action}()</span>
|
|
3546
|
+
<span
|
|
3547
|
+
class={[
|
|
3548
|
+
"ml-auto tabular text-xs font-semibold shrink-0",
|
|
3549
|
+
a.ms > 500
|
|
3550
|
+
? "text-destructive"
|
|
3551
|
+
: a.ms > 200
|
|
3552
|
+
? "text-warning"
|
|
3553
|
+
: "text-muted-foreground",
|
|
3554
|
+
]}
|
|
3555
|
+
>
|
|
3556
|
+
{a.ms}ms
|
|
3557
|
+
</span>
|
|
3558
|
+
</div>
|
|
3559
|
+
))
|
|
3560
|
+
)}
|
|
3561
|
+
</div>
|
|
3562
|
+
</Card>
|
|
3563
|
+
</div>
|
|
3564
|
+
|
|
3565
|
+
<Card class="overflow-hidden">
|
|
3566
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
3567
|
+
<span class="font-semibold text-foreground text-sm">
|
|
3568
|
+
Recent actions ·{" "}
|
|
3569
|
+
<span class="text-muted-foreground font-normal">{actions.length} shown</span>
|
|
3570
|
+
</span>
|
|
3571
|
+
<span class="text-xs text-muted-foreground">
|
|
3572
|
+
user · ip · queries — click for the trace
|
|
3573
|
+
</span>
|
|
3574
|
+
</div>
|
|
3575
|
+
<div class="overflow-x-auto">
|
|
3576
|
+
<table class="w-full text-sm">
|
|
3577
|
+
<thead class="text-left text-[11px] uppercase tracking-wide text-muted-foreground bg-muted">
|
|
3578
|
+
<tr>
|
|
3579
|
+
<th class="px-5 py-2 font-medium">Component</th>
|
|
3580
|
+
<th class="px-3 py-2 font-medium">Action</th>
|
|
3581
|
+
<th class="px-3 py-2 font-medium">User</th>
|
|
3582
|
+
<th class="px-3 py-2 font-medium">IP</th>
|
|
3583
|
+
<th class="px-3 py-2 font-medium">Queries</th>
|
|
3584
|
+
<th class="px-3 py-2 font-medium">Memory</th>
|
|
3585
|
+
<th class="px-3 py-2 font-medium">Time</th>
|
|
3586
|
+
<th class="px-3 py-2 font-medium">When</th>
|
|
3587
|
+
</tr>
|
|
3588
|
+
</thead>
|
|
3589
|
+
<tbody class="divide-y divide-border">
|
|
3590
|
+
{actionRows.length === 0 ? (
|
|
3591
|
+
<tr>
|
|
3592
|
+
<td colspan="8" class="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
3593
|
+
{r.recentActions.length === 0
|
|
3594
|
+
? "no WebSocket actions in this range"
|
|
3595
|
+
: "no actions match the filter"}
|
|
3596
|
+
</td>
|
|
3597
|
+
</tr>
|
|
3598
|
+
) : (
|
|
3599
|
+
actionRows.map((a) => this.renderWsActionRows(a))
|
|
3600
|
+
)}
|
|
3601
|
+
</tbody>
|
|
3602
|
+
</table>
|
|
3603
|
+
</div>
|
|
3604
|
+
{this._feedFooter(actions.length)}
|
|
3605
|
+
</Card>
|
|
3606
|
+
</section>
|
|
3607
|
+
);
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
/** Who is connected right now over the Flow WebSocket (live, not windowed). */
|
|
3611
|
+
private renderConnectedClients(): HtmlNode {
|
|
3612
|
+
const clients = this.snap.realtime.clients;
|
|
3613
|
+
return (
|
|
3614
|
+
<Card class="overflow-hidden">
|
|
3615
|
+
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
|
3616
|
+
<span class="font-semibold text-foreground text-sm">Connected clients</span>
|
|
3617
|
+
<span class="text-[11px] text-muted-foreground">{clients.length} online</span>
|
|
3618
|
+
</div>
|
|
3619
|
+
<div class="divide-y divide-border max-h-72 overflow-y-auto">
|
|
3620
|
+
{clients.length === 0 ? (
|
|
3621
|
+
<div class="px-5 py-8 text-center text-xs text-muted-foreground">
|
|
3622
|
+
no clients connected
|
|
3623
|
+
</div>
|
|
3624
|
+
) : (
|
|
3625
|
+
clients.map((c) => (
|
|
3626
|
+
<div key={c.id} class="px-5 py-2.5">
|
|
3627
|
+
<div class="flex items-center gap-2">
|
|
3628
|
+
<span
|
|
3629
|
+
class={[
|
|
3630
|
+
"w-1.5 h-1.5 rounded-full shrink-0",
|
|
3631
|
+
c.user ? "bg-success" : "bg-muted-foreground/40",
|
|
3632
|
+
]}
|
|
3633
|
+
/>
|
|
3634
|
+
<span class="text-xs text-foreground truncate flex-1">{c.user ?? "guest"}</span>
|
|
3635
|
+
<span class="tabular text-xs font-semibold text-foreground shrink-0">
|
|
3636
|
+
{commas(c.actions)}
|
|
3637
|
+
</span>
|
|
3638
|
+
</div>
|
|
3639
|
+
<div class="flex items-center gap-2 mt-0.5 pl-3.5 text-[11px] text-muted-foreground">
|
|
3640
|
+
<span class="font-mono truncate">{c.ip}</span>
|
|
3641
|
+
<span class="ml-auto shrink-0 whitespace-nowrap">{c.lastActivity}</span>
|
|
3642
|
+
</div>
|
|
3643
|
+
</div>
|
|
3644
|
+
))
|
|
3645
|
+
)}
|
|
3646
|
+
</div>
|
|
3647
|
+
</Card>
|
|
3648
|
+
);
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
private renderWsActionRows(a: WsAction): HtmlNode[] {
|
|
3652
|
+
const open = this.openWsAction === a.id;
|
|
3653
|
+
const rows: HtmlNode[] = [
|
|
3654
|
+
<tr
|
|
3655
|
+
key={`wa${a.id}`}
|
|
3656
|
+
class="hover:bg-accent/60 cursor-pointer"
|
|
3657
|
+
onClick={() => this.toggleWsAction(a.id)}
|
|
3658
|
+
>
|
|
3659
|
+
<td class="px-5 py-2.5">
|
|
3660
|
+
<span
|
|
3661
|
+
class={[
|
|
3662
|
+
"w-2 h-2 rounded-full inline-block mr-2",
|
|
3663
|
+
a.ok ? "bg-success" : "bg-destructive",
|
|
3664
|
+
]}
|
|
3665
|
+
/>
|
|
3666
|
+
<span class="font-mono text-xs text-foreground">{a.component}</span>
|
|
3667
|
+
</td>
|
|
3668
|
+
<td class="px-3 py-2.5 font-mono text-[11px] text-primary">{a.action}()</td>
|
|
3669
|
+
<td class="px-3 py-2.5 text-xs text-muted-foreground truncate max-w-[140px]">
|
|
3670
|
+
{a.user ?? "guest"}
|
|
3671
|
+
</td>
|
|
3672
|
+
<td class="px-3 py-2.5 font-mono text-[11px] text-muted-foreground whitespace-nowrap">
|
|
3673
|
+
{a.ip ?? "—"}
|
|
3674
|
+
</td>
|
|
3675
|
+
<td class="px-3 py-2.5 tabular text-muted-foreground">
|
|
3676
|
+
{a.queries.length}
|
|
3677
|
+
{a.nplus ? <span class="ml-1 text-[10px] font-bold text-destructive">N+1</span> : ""}
|
|
3678
|
+
</td>
|
|
3679
|
+
<td class="px-3 py-2.5 tabular text-xs text-muted-foreground whitespace-nowrap">
|
|
3680
|
+
{this._fmtMem(a.memKb)}
|
|
3681
|
+
</td>
|
|
3682
|
+
<td
|
|
3683
|
+
class={[
|
|
3684
|
+
"px-3 py-2.5 tabular",
|
|
3685
|
+
a.ms > 500 ? "text-destructive" : a.ms > 200 ? "text-warning" : "text-muted-foreground",
|
|
3686
|
+
]}
|
|
3687
|
+
>
|
|
3688
|
+
{a.ms}ms
|
|
3689
|
+
</td>
|
|
3690
|
+
<td class="px-3 py-2.5 text-[11px] text-muted-foreground tabular whitespace-nowrap">
|
|
3691
|
+
{a.when}
|
|
3692
|
+
</td>
|
|
3693
|
+
</tr>,
|
|
3694
|
+
];
|
|
3695
|
+
if (open) {
|
|
3696
|
+
rows.push(
|
|
3697
|
+
<tr key={`wd${a.id}`}>
|
|
3698
|
+
<td colspan="8" class="px-5 pb-4 pt-1 bg-muted/50">
|
|
3699
|
+
{Object.keys(a.context).length > 0 ? (
|
|
3700
|
+
<div class="flex flex-wrap items-center gap-1.5 px-1 py-2">
|
|
3701
|
+
{this._contextChips(a.context)}
|
|
3702
|
+
</div>
|
|
3703
|
+
) : (
|
|
3704
|
+
""
|
|
3705
|
+
)}
|
|
3706
|
+
<div class="rounded-lg bg-foreground p-4 font-mono text-[11px] text-muted-foreground space-y-1 overflow-x-auto">
|
|
3707
|
+
<div class="text-muted-foreground">— SQL queries ({a.queries.length}) —</div>
|
|
3708
|
+
{a.queries.map((q, qi) => (
|
|
3709
|
+
<div key={qi} class="flex gap-3">
|
|
3710
|
+
<span class="text-primary/80 shrink-0 tabular">{q.ms}ms</span>
|
|
3711
|
+
<span class="text-muted-foreground truncate">{q.sql}</span>
|
|
3712
|
+
</div>
|
|
3713
|
+
))}
|
|
3714
|
+
{a.queries.length === 0 ? <div class="text-muted-foreground">no queries</div> : ""}
|
|
3715
|
+
</div>
|
|
3716
|
+
</td>
|
|
3717
|
+
</tr>,
|
|
3718
|
+
);
|
|
3719
|
+
}
|
|
3720
|
+
return rows;
|
|
3721
|
+
}
|
|
3722
|
+
}
|