@rapidmx/web-client 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/apps/admin/diagnostics/index.tsx +15 -0
  2. package/apps/shared/components/admin/diagnostics/Badge.tsx +37 -0
  3. package/apps/shared/components/admin/diagnostics/ComponentsTable.tsx +131 -0
  4. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +173 -0
  5. package/apps/shared/components/admin/diagnostics/HostCard.tsx +108 -0
  6. package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +136 -0
  7. package/apps/shared/components/admin/diagnostics/KubernetesNotice.tsx +25 -0
  8. package/apps/shared/components/admin/diagnostics/LogStore.ts +75 -0
  9. package/apps/shared/components/admin/diagnostics/LogViewer.tsx +87 -0
  10. package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +235 -0
  11. package/apps/shared/components/admin/diagnostics/MetricTile.tsx +43 -0
  12. package/apps/shared/components/admin/diagnostics/NamespaceCard.tsx +95 -0
  13. package/apps/shared/components/admin/diagnostics/PackagesTable.tsx +107 -0
  14. package/apps/shared/components/admin/diagnostics/ProcessCard.tsx +70 -0
  15. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +79 -0
  16. package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +110 -0
  17. package/apps/shared/components/admin/diagnostics/ServerVersionCard.tsx +37 -0
  18. package/apps/shared/components/admin/diagnostics/Sparkline.tsx +79 -0
  19. package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +81 -0
  20. package/apps/shared/components/admin/diagnostics/UsageMeter.tsx +73 -0
  21. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +36 -0
  22. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +197 -0
  23. package/apps/shared/components/admin/diagnostics/download.ts +28 -0
  24. package/apps/shared/components/admin/diagnostics/format.ts +106 -0
  25. package/apps/shared/components/admin/diagnostics/logClient.ts +231 -0
  26. package/apps/shared/components/admin/diagnostics/logLines.ts +125 -0
  27. package/apps/shared/components/admin/diagnostics/metricsHistory.ts +28 -0
  28. package/apps/shared/components/admin/diagnostics/useDiagnosticsResource.ts +44 -0
  29. package/apps/shared/components/admin/diagnostics/useLogStream.ts +122 -0
  30. package/apps/shared/components/admin/diagnostics/useMetricsPolling.ts +84 -0
  31. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -0
  32. package/dist/apps/admin/diagnostics/index.d.ts +3 -0
  33. package/dist/apps/admin/diagnostics/index.js +6 -0
  34. package/dist/apps/shared/components/admin/diagnostics/Badge.d.ts +10 -0
  35. package/dist/apps/shared/components/admin/diagnostics/Badge.js +15 -0
  36. package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.d.ts +15 -0
  37. package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.js +35 -0
  38. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +17 -0
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +111 -0
  40. package/dist/apps/shared/components/admin/diagnostics/HostCard.d.ts +13 -0
  41. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +33 -0
  42. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.d.ts +26 -0
  43. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +60 -0
  44. package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.d.ts +8 -0
  45. package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.js +9 -0
  46. package/dist/apps/shared/components/admin/diagnostics/LogStore.d.ts +37 -0
  47. package/dist/apps/shared/components/admin/diagnostics/LogStore.js +50 -0
  48. package/dist/apps/shared/components/admin/diagnostics/LogViewer.d.ts +24 -0
  49. package/dist/apps/shared/components/admin/diagnostics/LogViewer.js +47 -0
  50. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.d.ts +13 -0
  51. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +63 -0
  52. package/dist/apps/shared/components/admin/diagnostics/MetricTile.d.ts +21 -0
  53. package/dist/apps/shared/components/admin/diagnostics/MetricTile.js +7 -0
  54. package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.d.ts +13 -0
  55. package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.js +15 -0
  56. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.d.ts +8 -0
  57. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.js +31 -0
  58. package/dist/apps/shared/components/admin/diagnostics/ProcessCard.d.ts +8 -0
  59. package/dist/apps/shared/components/admin/diagnostics/ProcessCard.js +18 -0
  60. package/dist/apps/shared/components/admin/diagnostics/PvcTable.d.ts +6 -0
  61. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +18 -0
  62. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.d.ts +7 -0
  63. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +28 -0
  64. package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.d.ts +6 -0
  65. package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.js +17 -0
  66. package/dist/apps/shared/components/admin/diagnostics/Sparkline.d.ts +22 -0
  67. package/dist/apps/shared/components/admin/diagnostics/Sparkline.js +34 -0
  68. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.d.ts +6 -0
  69. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +26 -0
  70. package/dist/apps/shared/components/admin/diagnostics/UsageMeter.d.ts +26 -0
  71. package/dist/apps/shared/components/admin/diagnostics/UsageMeter.js +25 -0
  72. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +10 -0
  73. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +11 -0
  74. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +168 -0
  75. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +17 -0
  76. package/dist/apps/shared/components/admin/diagnostics/download.d.ts +9 -0
  77. package/dist/apps/shared/components/admin/diagnostics/download.js +23 -0
  78. package/dist/apps/shared/components/admin/diagnostics/format.d.ts +24 -0
  79. package/dist/apps/shared/components/admin/diagnostics/format.js +91 -0
  80. package/dist/apps/shared/components/admin/diagnostics/logClient.d.ts +85 -0
  81. package/dist/apps/shared/components/admin/diagnostics/logClient.js +173 -0
  82. package/dist/apps/shared/components/admin/diagnostics/logLines.d.ts +47 -0
  83. package/dist/apps/shared/components/admin/diagnostics/logLines.js +86 -0
  84. package/dist/apps/shared/components/admin/diagnostics/metricsHistory.d.ts +9 -0
  85. package/dist/apps/shared/components/admin/diagnostics/metricsHistory.js +19 -0
  86. package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.d.ts +12 -0
  87. package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.js +30 -0
  88. package/dist/apps/shared/components/admin/diagnostics/useLogStream.d.ts +37 -0
  89. package/dist/apps/shared/components/admin/diagnostics/useLogStream.js +79 -0
  90. package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.d.ts +20 -0
  91. package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.js +69 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  94. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { LogEntry } from "./logLines.js";
6
+
7
+ /** How many lines the log view keeps. Older lines are dropped, and counted. */
8
+ export const LOG_BUFFER_LIMIT = 5000;
9
+
10
+ /** How many lines one capture records. */
11
+ export const LOG_CAPTURE_LIMIT = 50_000;
12
+
13
+ /** What a capture has recorded so far. */
14
+ export interface LogCapture {
15
+ /** Whether lines are still being recorded. */
16
+ active: boolean;
17
+ /** When it started and, once stopped, ended (`Date.now()`). */
18
+ startedAt: number;
19
+ stoppedAt: number | undefined;
20
+ entries: LogEntry[];
21
+ /** How many lines arrived after `LOG_CAPTURE_LIMIT` was reached, and so were not recorded. */
22
+ missed: number;
23
+ }
24
+
25
+ /**
26
+ * Where the log view's lines are kept, outside React: a burst of lines is added without a render each. The display buffer holds the
27
+ * last `LOG_BUFFER_LIMIT` lines. A capture is separate: it records every line that arrives while it runs, whatever the display
28
+ * filters show and however many lines the display buffer has since dropped, up to `LOG_CAPTURE_LIMIT`.
29
+ */
30
+ export class LogStore {
31
+ entries: LogEntry[] = [];
32
+ /** How many lines have been dropped from the front of `entries`. */
33
+ dropped = 0;
34
+ capture: LogCapture | undefined;
35
+
36
+ constructor(
37
+ private readonly bufferLimit = LOG_BUFFER_LIMIT,
38
+ private readonly captureLimit = LOG_CAPTURE_LIMIT
39
+ ) {}
40
+
41
+ add(entry: LogEntry): void {
42
+ this.entries.push(entry);
43
+ if (this.entries.length > this.bufferLimit) {
44
+ const excess = this.entries.length - this.bufferLimit;
45
+ this.entries.splice(0, excess);
46
+ this.dropped += excess;
47
+ }
48
+ const capture = this.capture;
49
+ if (capture?.active) {
50
+ if (capture.entries.length < this.captureLimit) {
51
+ capture.entries.push(entry);
52
+ } else {
53
+ capture.missed += 1;
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Empties the display buffer. A capture in progress is not touched. */
59
+ clear(): void {
60
+ this.entries = [];
61
+ this.dropped = 0;
62
+ }
63
+
64
+ /** Starts recording (from nothing: an earlier capture is replaced). */
65
+ startCapture(now: number): void {
66
+ this.capture = { active: true, startedAt: now, stoppedAt: undefined, entries: [], missed: 0 };
67
+ }
68
+
69
+ /** Stops recording. What was recorded is kept for download. */
70
+ stopCapture(now: number): void {
71
+ if (this.capture?.active) {
72
+ this.capture = { ...this.capture, active: false, stoppedAt: now };
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,87 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useRef } from "react";
6
+ import { entryTime, LogEntry } from "./logLines.js";
7
+
8
+ /** The most rows the viewer puts in the page: the last this many lines that match. (The buffer behind it is bigger.) */
9
+ export const LOG_VISIBLE_ROWS = 500;
10
+
11
+ /** The tint behind a level's name. The name is always written, so the tint only helps the eye find it. */
12
+ const LEVEL_CLASSES: Record<string, string> = {
13
+ error: "bg-danger/20",
14
+ warn: "bg-warning/30",
15
+ info: "bg-primary/15",
16
+ http: "bg-accent/20",
17
+ };
18
+
19
+ /** What is written in a line's level column: the level, or a marker for a line without one. */
20
+ export function levelLabel(entry: LogEntry): string {
21
+ if (entry.level) {
22
+ return entry.level.toUpperCase();
23
+ }
24
+ return entry.structured ? "—" : "RAW";
25
+ }
26
+
27
+ /** A line's `stack` (which is shown as lines of its own), and its other extra fields. */
28
+ export function splitExtra(extra: Record<string, unknown>): { stack: string | undefined; rest: Record<string, unknown> } {
29
+ const { stack, ...rest } = extra;
30
+ return typeof stack === "string" ? { stack, rest } : { stack: undefined, rest: extra };
31
+ }
32
+
33
+ export function LevelBadge({ entry }: { entry: LogEntry }) {
34
+ return (
35
+ <span
36
+ className={`inline-block w-16 shrink-0 rounded-sm px-1 text-center font-semibold ${LEVEL_CLASSES[entry.level] ?? "bg-surface"}`}
37
+ >
38
+ {levelLabel(entry)}
39
+ </span>
40
+ );
41
+ }
42
+
43
+ function LogRow({ entry }: { entry: LogEntry }) {
44
+ const { stack, rest } = splitExtra(entry.extra);
45
+ return (
46
+ <div className="py-0.5 break-words">
47
+ <span className="text-text-muted">{entryTime(entry)}</span> <LevelBadge entry={entry} /> <span className="whitespace-pre-wrap">{entry.message}</span>
48
+ {Object.keys(rest).length > 0 && <span className="text-text-muted"> {JSON.stringify(rest)}</span>}
49
+ {stack !== undefined && <div className="whitespace-pre-wrap pl-4 text-text-muted">{stack}</div>}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ export interface LogViewerProps {
55
+ /** The lines to show, oldest first; only the last `LOG_VISIBLE_ROWS` are put in the page. */
56
+ entries: LogEntry[];
57
+ /** Whether the view stays scrolled to the newest line. */
58
+ follow: boolean;
59
+ /** Said when there is nothing to show. */
60
+ empty: string;
61
+ }
62
+
63
+ /** The lines, as monospace text in a scrolling box. The box is a live log region, but not announced line by line. */
64
+ export default function LogViewer({ entries, follow, empty }: LogViewerProps) {
65
+ const box = useRef<HTMLDivElement>(null);
66
+ const shown = entries.slice(-LOG_VISIBLE_ROWS);
67
+
68
+ useEffect(() => {
69
+ if (follow) {
70
+ // Mounted by the time an effect runs.
71
+ box.current!.scrollTop = box.current!.scrollHeight;
72
+ }
73
+ }, [shown.length, entries[entries.length - 1]?.id, follow]);
74
+
75
+ return (
76
+ <div
77
+ ref={box}
78
+ role="log"
79
+ aria-label="Server log"
80
+ aria-live="off"
81
+ tabIndex={0}
82
+ className="h-[28rem] overflow-auto rounded-md border border-border bg-surface-alt p-2 font-mono text-xs"
83
+ >
84
+ {shown.length === 0 ? <p className="text-text-muted">{empty}</p> : shown.map((entry) => <LogRow key={entry.id} entry={entry} />)}
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,235 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useMemo, useState } from "react";
6
+ import {
7
+ HiOutlineArrowDownTray,
8
+ HiOutlineArrowPath,
9
+ HiOutlineCheck,
10
+ HiOutlineExclamationTriangle,
11
+ HiOutlinePause,
12
+ HiOutlinePlay,
13
+ HiOutlineSignal,
14
+ HiOutlineSignalSlash,
15
+ HiOutlineStop,
16
+ HiOutlineTrash,
17
+ } from "react-icons/hi2";
18
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
19
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
20
+ import Badge, { BadgeTone } from "./Badge.js";
21
+ import { SaveFile, timestampedFilename } from "./download.js";
22
+ import { formatUptime } from "./format.js";
23
+ import type { LogStreamStatus } from "./logClient.js";
24
+ import { entriesToLog, entriesToNdjson, entryMatches, LOG_LEVELS, LogEntry, levelVisible } from "./logLines.js";
25
+ import { LOG_BUFFER_LIMIT, LOG_CAPTURE_LIMIT } from "./LogStore.js";
26
+ import LogViewer, { LOG_VISIBLE_ROWS } from "./LogViewer.js";
27
+ import type { LogStream } from "./useLogStream.js";
28
+
29
+ const STATUS: Record<LogStreamStatus, { label: string; tone: BadgeTone; icon: typeof HiOutlineSignal }> = {
30
+ connecting: { label: "Connecting", tone: "warning", icon: HiOutlineArrowPath },
31
+ live: { label: "Live", tone: "success", icon: HiOutlineSignal },
32
+ reconnecting: { label: "Reconnecting", tone: "warning", icon: HiOutlineArrowPath },
33
+ closed: { label: "Stopped", tone: "neutral", icon: HiOutlineSignalSlash },
34
+ error: { label: "Error", tone: "danger", icon: HiOutlineExclamationTriangle },
35
+ };
36
+
37
+ const INPUT_CLASS =
38
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
39
+
40
+ type Format = "log" | "ndjson";
41
+
42
+ const MIME_TYPES: Record<Format, string> = { log: "text/plain;charset=utf-8", ndjson: "application/x-ndjson" };
43
+
44
+ export interface LogsPanelProps {
45
+ stream: LogStream;
46
+ /** Saves a download. */
47
+ saveFile: SaveFile;
48
+ }
49
+
50
+ /**
51
+ * The Logs tab: the server's log stream as it happens, with filters, and captures. A capture records every line from the
52
+ * moment it starts, whatever the filters show, and can be downloaded (as can the display buffer) as a `.log` or `.ndjson` file.
53
+ */
54
+ export default function LogsPanel({ stream, saveFile }: LogsPanelProps) {
55
+ const { status, error, running, entries, dropped, capture } = stream;
56
+ const [levels, setLevels] = useState<ReadonlySet<string>>(() => new Set(LOG_LEVELS));
57
+ const [search, setSearch] = useState("");
58
+ const [follow, setFollow] = useState(true);
59
+ const [now, setNow] = useState(() => Date.now());
60
+
61
+ // The clock the recording time counts against, ticking only while a capture records.
62
+ const recording = capture?.active === true;
63
+ useEffect(() => {
64
+ if (!recording) {
65
+ return;
66
+ }
67
+ setNow(Date.now());
68
+ const timer = setInterval(() => setNow(Date.now()), 1000);
69
+ return () => clearInterval(timer);
70
+ }, [recording]);
71
+
72
+ const needle = search.trim().toLowerCase();
73
+ const matching = useMemo(
74
+ () => entries.filter((entry) => levelVisible(entry, levels) && entryMatches(entry, needle)),
75
+ [entries, levels, needle]
76
+ );
77
+ const filtered = matching.length !== entries.length;
78
+ const badge = STATUS[status];
79
+
80
+ function toggleLevel(level: string) {
81
+ const next = new Set(levels);
82
+ if (!next.delete(level)) {
83
+ next.add(level);
84
+ }
85
+ setLevels(next);
86
+ }
87
+
88
+ function download(lines: LogEntry[], format: Format) {
89
+ saveFile(
90
+ timestampedFilename("rapidmx-server-logs", format),
91
+ format === "log" ? entriesToLog(lines) : entriesToNdjson(lines),
92
+ MIME_TYPES[format]
93
+ );
94
+ }
95
+
96
+ return (
97
+ <div className="space-y-4">
98
+ <div className="flex flex-wrap items-center gap-3">
99
+ <Badge tone={badge.tone} icon={badge.icon}>
100
+ {badge.label}
101
+ </Badge>
102
+ {running ? (
103
+ <Button type="button" variant="secondary" className="!w-auto" onClick={stream.stop}>
104
+ <HiOutlineStop size={16} aria-hidden="true" className="mr-1 inline" />
105
+ Stop
106
+ </Button>
107
+ ) : (
108
+ <Button type="button" className="!w-auto" onClick={stream.start}>
109
+ <HiOutlinePlay size={16} aria-hidden="true" className="mr-1 inline" />
110
+ Start
111
+ </Button>
112
+ )}
113
+ <Button type="button" variant="secondary" className="!w-auto" aria-pressed={!follow} onClick={() => setFollow(!follow)}>
114
+ <HiOutlinePause size={16} aria-hidden="true" className="mr-1 inline" />
115
+ {follow ? "Pause scrolling" : "Resume scrolling"}
116
+ </Button>
117
+ <Button type="button" variant="secondary" className="!w-auto" onClick={stream.clear}>
118
+ <HiOutlineTrash size={16} aria-hidden="true" className="mr-1 inline" />
119
+ Clear
120
+ </Button>
121
+ </div>
122
+
123
+ {error && <Alert>{error}</Alert>}
124
+
125
+ <div className="flex flex-wrap items-center gap-3">
126
+ <div role="group" aria-label="Levels" className="flex flex-wrap items-center gap-1">
127
+ {LOG_LEVELS.map((level) => (
128
+ <button
129
+ key={level}
130
+ type="button"
131
+ aria-pressed={levels.has(level)}
132
+ onClick={() => toggleLevel(level)}
133
+ className={`inline-flex items-center gap-1 rounded-pill border px-2.5 py-1 text-xs font-medium ${
134
+ levels.has(level) ? "border-primary bg-primary/10 text-text" : "border-border bg-surface text-text-muted"
135
+ }`}
136
+ >
137
+ {levels.has(level) && <HiOutlineCheck size={12} aria-hidden="true" />}
138
+ {level}
139
+ </button>
140
+ ))}
141
+ </div>
142
+ <div className="min-w-48 flex-1">
143
+ <input
144
+ type="search"
145
+ aria-label="Search the log"
146
+ className={INPUT_CLASS}
147
+ placeholder="Search the log"
148
+ value={search}
149
+ onChange={(event) => setSearch(event.target.value)}
150
+ />
151
+ </div>
152
+ </div>
153
+
154
+ <div role="status" className="text-xs text-text-muted">
155
+ {filtered
156
+ ? `${matching.length} of ${entries.length} buffered lines match.`
157
+ : `${entries.length} buffered ${entries.length === 1 ? "line" : "lines"}.`}
158
+ {matching.length > LOG_VISIBLE_ROWS && ` Showing the last ${LOG_VISIBLE_ROWS}.`}
159
+ {dropped > 0 && ` ${dropped} older ${dropped === 1 ? "line" : "lines"} dropped (the buffer keeps the last ${LOG_BUFFER_LIMIT}).`}
160
+ </div>
161
+
162
+ <LogViewer
163
+ entries={matching}
164
+ follow={follow}
165
+ empty={entries.length === 0 ? "No log lines yet." : "No buffered line matches the filters."}
166
+ />
167
+
168
+ <section aria-labelledby="diagnostics-capture-heading" className="rounded-md border border-border bg-surface p-4">
169
+ <h2 id="diagnostics-capture-heading" className="text-base font-bold uppercase tracking-wide mb-1">
170
+ Capture
171
+ </h2>
172
+ <p className="mb-3 text-xs text-text-muted">
173
+ A capture records every line received while it runs, whatever the filters show, up to {LOG_CAPTURE_LIMIT.toLocaleString()} lines.
174
+ </p>
175
+ <div className="flex flex-wrap items-center gap-3">
176
+ {recording ? (
177
+ <Button type="button" variant="secondary" className="!w-auto" onClick={stream.stopCapture}>
178
+ <HiOutlineStop size={16} aria-hidden="true" className="mr-1 inline" />
179
+ Stop capture
180
+ </Button>
181
+ ) : (
182
+ <Button type="button" className="!w-auto" onClick={stream.startCapture}>
183
+ <HiOutlinePlay size={16} aria-hidden="true" className="mr-1 inline" />
184
+ Start capture
185
+ </Button>
186
+ )}
187
+ {capture && (
188
+ <p role="status" className="text-sm">
189
+ {recording ? "Recording" : "Capture stopped"}: {capture.count.toLocaleString()} {capture.count === 1 ? "line" : "lines"} in{" "}
190
+ {formatUptime(((capture.stoppedAt ?? now) - capture.startedAt) / 1000)}
191
+ </p>
192
+ )}
193
+ </div>
194
+ {capture && capture.missed > 0 && (
195
+ <p className="mt-2 text-sm">
196
+ The capture is full: {capture.missed.toLocaleString()} later {capture.missed === 1 ? "line was" : "lines were"} not recorded.
197
+ </p>
198
+ )}
199
+ <div className="mt-3 flex flex-wrap items-center gap-2">
200
+ <span className="text-xs uppercase tracking-wide text-text-muted">Download</span>
201
+ {(["log", "ndjson"] as const).map((format) => (
202
+ <Button
203
+ key={`buffer-${format}`}
204
+ type="button"
205
+ variant="secondary"
206
+ className="!w-auto"
207
+ disabled={entries.length === 0}
208
+ onClick={() => download(entries, format)}
209
+ >
210
+ <HiOutlineArrowDownTray size={16} aria-hidden="true" className="mr-1 inline" />
211
+ {`Buffer (.${format})`}
212
+ </Button>
213
+ ))}
214
+ {capture &&
215
+ (["log", "ndjson"] as const).map((format) => (
216
+ <Button
217
+ key={`capture-${format}`}
218
+ type="button"
219
+ variant="secondary"
220
+ className="!w-auto"
221
+ disabled={capture.count === 0}
222
+ onClick={() => download(stream.captureEntries(), format)}
223
+ >
224
+ <HiOutlineArrowDownTray size={16} aria-hidden="true" className="mr-1 inline" />
225
+ {`Capture (.${format})`}
226
+ </Button>
227
+ ))}
228
+ </div>
229
+ <p className="mt-2 text-xs text-text-muted">
230
+ The buffer download holds every buffered line, not only the ones the filters show.
231
+ </p>
232
+ </section>
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,43 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode } from "react";
6
+ import Sparkline, { SparklineProps } from "./Sparkline.js";
7
+ import UsageMeter from "./UsageMeter.js";
8
+
9
+ export interface MetricTileProps {
10
+ /** What is measured (`CPU`). */
11
+ label: string;
12
+ /** The current value, written out (`1.2 cores`). */
13
+ value: string;
14
+ /** Smaller text under the value. */
15
+ detail?: string;
16
+ /** When the value is a share of a capacity: the share (0 to 100) and the amounts it comes from. */
17
+ usage?: { percent: number; detail: string; flag?: boolean };
18
+ /** The value's recent history, drawn under it. */
19
+ history?: Omit<SparklineProps, "label">;
20
+ children?: ReactNode;
21
+ }
22
+
23
+ /** One measurement: its label, the current value in numbers, a meter when it has a capacity, and a sparkline of its recent past. */
24
+ export default function MetricTile({ label, value, detail, usage, history, children }: MetricTileProps) {
25
+ return (
26
+ <div className="rounded-md border border-border bg-surface p-4">
27
+ <div className="text-xs uppercase tracking-wide text-text-muted">{label}</div>
28
+ <div className="mt-1 text-xl font-semibold">{value}</div>
29
+ {detail && <div className="text-xs text-text-muted">{detail}</div>}
30
+ {usage && (
31
+ <div className="mt-3">
32
+ <UsageMeter label={`${label} usage`} percent={usage.percent} detail={usage.detail} flag={usage.flag} />
33
+ </div>
34
+ )}
35
+ {history && (
36
+ <div className="mt-3">
37
+ <Sparkline label={label} {...history} />
38
+ </div>
39
+ )}
40
+ {children}
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,95 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineInformationCircle } from "react-icons/hi2";
7
+ import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
8
+ import { formatBytes, formatCores, NO_VALUE } from "./format.js";
9
+ import { seriesOf } from "./metricsHistory.js";
10
+ import MetricTile from "./MetricTile.js";
11
+
12
+ type NamespaceMetrics = NonNullable<DiagnosticsMetrics["kubernetes"]["namespace"]>;
13
+
14
+ export interface NamespaceCardProps {
15
+ namespace: NamespaceMetrics | undefined;
16
+ history: DiagnosticsMetrics[];
17
+ }
18
+
19
+ const POD_HEADINGS = ["Pod", "Component", "CPU", "Memory"];
20
+
21
+ /**
22
+ * The install's namespace: what all its pods use together, and what each pod uses. The figures come from metrics-server; without
23
+ * it there are none, and that is said as a hint (it is a choice of the cluster), not as a failure.
24
+ */
25
+ export default function NamespaceCard({ namespace, history }: NamespaceCardProps) {
26
+ // Biggest memory first; a pod with no figure last.
27
+ const pods = [...(namespace?.pods ?? [])].sort((a, b) => (b.memoryUsedBytes ?? -1) - (a.memoryUsedBytes ?? -1));
28
+ return (
29
+ <section aria-labelledby="diagnostics-namespace-heading" className="rounded-md border border-border bg-surface p-4">
30
+ <h2 id="diagnostics-namespace-heading" className="text-base font-bold uppercase tracking-wide mb-3">
31
+ Namespace{namespace?.name ? ` ${namespace.name}` : ""}
32
+ </h2>
33
+ {!namespace ? (
34
+ <p className="text-sm text-text-muted">The server did not report the namespace&rsquo;s figures.</p>
35
+ ) : !namespace.podMetricsAvailable ? (
36
+ <div role="status" className="flex items-start gap-3 rounded-md border border-border bg-surface-alt p-4 text-sm">
37
+ <HiOutlineInformationCircle size={20} aria-hidden="true" className="mt-0.5 shrink-0 text-text-muted" />
38
+ <div>
39
+ <p className="font-medium">Per-pod CPU and memory are not available.</p>
40
+ <p className="mt-1 text-text-muted">
41
+ They come from metrics-server, which this cluster does not provide or the server may not read.
42
+ {namespace.podMetricsReason ? ` ${namespace.podMetricsReason}` : ""}
43
+ </p>
44
+ </div>
45
+ </div>
46
+ ) : (
47
+ <>
48
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
49
+ <MetricTile
50
+ label="Namespace CPU"
51
+ value={formatCores(namespace.cpuUsedCores)}
52
+ history={{ values: seriesOf(history, (sample) => sample.kubernetes?.namespace?.cpuUsedCores), format: formatCores }}
53
+ />
54
+ <MetricTile
55
+ label="Namespace memory"
56
+ value={formatBytes(namespace.memoryUsedBytes)}
57
+ history={{ values: seriesOf(history, (sample) => sample.kubernetes?.namespace?.memoryUsedBytes), format: formatBytes }}
58
+ />
59
+ </div>
60
+ <h3 className="mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted">Pods</h3>
61
+ {pods.length === 0 ? (
62
+ <p className="text-sm text-text-muted">No pod figures were reported.</p>
63
+ ) : (
64
+ <div className="overflow-x-auto">
65
+ <table className="w-full text-sm border-collapse">
66
+ <thead>
67
+ <tr>
68
+ {POD_HEADINGS.map((heading) => (
69
+ <th
70
+ key={heading}
71
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
72
+ >
73
+ {heading}
74
+ </th>
75
+ ))}
76
+ </tr>
77
+ </thead>
78
+ <tbody>
79
+ {pods.map((pod) => (
80
+ <tr key={pod.name}>
81
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">{pod.name}</td>
82
+ <td className="py-2 px-2.5 border-b border-border">{pod.component ?? NO_VALUE}</td>
83
+ <td className="py-2 px-2.5 border-b border-border tabular-nums">{formatCores(pod.cpuUsedCores)}</td>
84
+ <td className="py-2 px-2.5 border-b border-border tabular-nums">{formatBytes(pod.memoryUsedBytes)}</td>
85
+ </tr>
86
+ ))}
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+ )}
91
+ </>
92
+ )}
93
+ </section>
94
+ );
95
+ }
@@ -0,0 +1,107 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useMemo, useState } from "react";
6
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
7
+ import type { DiagnosticsVersions } from "./diagnosticsApi.js";
8
+ import Badge from "./Badge.js";
9
+
10
+ /** How many packages are listed at first, and how many more each "Show more" adds: the list can be a thousand long. */
11
+ export const PACKAGES_PAGE_SIZE = 100;
12
+
13
+ const INPUT_CLASS =
14
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+
16
+ /** Every package installed on the server, filtered by a name and paged, with a badge on the ones the server itself depends on. */
17
+ export default function PackagesTable({ packages }: { packages: DiagnosticsVersions["packages"] }) {
18
+ const [query, setQuery] = useState("");
19
+ const [directOnly, setDirectOnly] = useState(false);
20
+ const [limit, setLimit] = useState(PACKAGES_PAGE_SIZE);
21
+
22
+ const matching = useMemo(() => {
23
+ const needle = query.trim().toLowerCase();
24
+ return packages.filter(
25
+ (item) => (!directOnly || item.direct) && (needle === "" || `${item.name} ${item.version}`.toLowerCase().includes(needle))
26
+ );
27
+ }, [packages, query, directOnly]);
28
+ const shown = matching.slice(0, limit);
29
+
30
+ return (
31
+ <section aria-labelledby="diagnostics-packages-heading" className="rounded-md border border-border bg-surface p-4">
32
+ <h2 id="diagnostics-packages-heading" className="text-base font-bold uppercase tracking-wide mb-3">
33
+ Installed packages
34
+ </h2>
35
+ <div className="mb-3 flex flex-wrap items-center gap-3">
36
+ <div className="min-w-48 flex-1">
37
+ <input
38
+ type="search"
39
+ aria-label="Filter packages"
40
+ className={INPUT_CLASS}
41
+ placeholder="Filter by name or version"
42
+ value={query}
43
+ onChange={(event) => {
44
+ setQuery(event.target.value);
45
+ setLimit(PACKAGES_PAGE_SIZE);
46
+ }}
47
+ />
48
+ </div>
49
+ <label className="flex items-center gap-2 text-sm">
50
+ <input
51
+ type="checkbox"
52
+ checked={directOnly}
53
+ onChange={(event) => {
54
+ setDirectOnly(event.target.checked);
55
+ setLimit(PACKAGES_PAGE_SIZE);
56
+ }}
57
+ />
58
+ Direct dependencies only
59
+ </label>
60
+ </div>
61
+ <p role="status" className="mb-2 text-xs text-text-muted">
62
+ {matching.length === packages.length
63
+ ? `${packages.length} packages`
64
+ : `${matching.length} of ${packages.length} packages match`}
65
+ </p>
66
+ {matching.length === 0 ? (
67
+ <p className="text-sm text-text-muted">No packages match.</p>
68
+ ) : (
69
+ <div className="overflow-x-auto">
70
+ <table className="w-full text-sm border-collapse">
71
+ <thead>
72
+ <tr>
73
+ {["Package", "Version", ""].map((heading) => (
74
+ <th
75
+ key={heading}
76
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
77
+ >
78
+ {heading}
79
+ </th>
80
+ ))}
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ {shown.map((item) => (
85
+ <tr key={`${item.name}@${item.version}`}>
86
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">{item.name}</td>
87
+ <td className="py-2 px-2.5 border-b border-border font-mono">{item.version}</td>
88
+ <td className="py-2 px-2.5 border-b border-border">{item.direct && <Badge tone="info">direct</Badge>}</td>
89
+ </tr>
90
+ ))}
91
+ </tbody>
92
+ </table>
93
+ </div>
94
+ )}
95
+ {shown.length < matching.length && (
96
+ <div className="mt-3 flex items-center gap-3">
97
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setLimit(limit + PACKAGES_PAGE_SIZE)}>
98
+ Show more
99
+ </Button>
100
+ <span className="text-xs text-text-muted">
101
+ Showing {shown.length} of {matching.length}
102
+ </span>
103
+ </div>
104
+ )}
105
+ </section>
106
+ );
107
+ }