@rapidmx/web-client 0.17.0 → 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 (107) 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/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +1 -2
  34. package/apps/shared/components/mail/layout/MailShell.tsx +1 -2
  35. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -2
  36. package/apps/shared/components/tasks/layout/TasksShell.tsx +1 -2
  37. package/apps/shared/navigation/index.ts +29 -2
  38. package/dist/apps/admin/diagnostics/index.d.ts +3 -0
  39. package/dist/apps/admin/diagnostics/index.js +6 -0
  40. package/dist/apps/shared/components/admin/diagnostics/Badge.d.ts +10 -0
  41. package/dist/apps/shared/components/admin/diagnostics/Badge.js +15 -0
  42. package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.d.ts +15 -0
  43. package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.js +35 -0
  44. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +17 -0
  45. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +111 -0
  46. package/dist/apps/shared/components/admin/diagnostics/HostCard.d.ts +13 -0
  47. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +33 -0
  48. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.d.ts +26 -0
  49. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +60 -0
  50. package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.d.ts +8 -0
  51. package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.js +9 -0
  52. package/dist/apps/shared/components/admin/diagnostics/LogStore.d.ts +37 -0
  53. package/dist/apps/shared/components/admin/diagnostics/LogStore.js +50 -0
  54. package/dist/apps/shared/components/admin/diagnostics/LogViewer.d.ts +24 -0
  55. package/dist/apps/shared/components/admin/diagnostics/LogViewer.js +47 -0
  56. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.d.ts +13 -0
  57. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +63 -0
  58. package/dist/apps/shared/components/admin/diagnostics/MetricTile.d.ts +21 -0
  59. package/dist/apps/shared/components/admin/diagnostics/MetricTile.js +7 -0
  60. package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.d.ts +13 -0
  61. package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.js +15 -0
  62. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.d.ts +8 -0
  63. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.js +31 -0
  64. package/dist/apps/shared/components/admin/diagnostics/ProcessCard.d.ts +8 -0
  65. package/dist/apps/shared/components/admin/diagnostics/ProcessCard.js +18 -0
  66. package/dist/apps/shared/components/admin/diagnostics/PvcTable.d.ts +6 -0
  67. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +18 -0
  68. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.d.ts +7 -0
  69. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +28 -0
  70. package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.d.ts +6 -0
  71. package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.js +17 -0
  72. package/dist/apps/shared/components/admin/diagnostics/Sparkline.d.ts +22 -0
  73. package/dist/apps/shared/components/admin/diagnostics/Sparkline.js +34 -0
  74. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.d.ts +6 -0
  75. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +26 -0
  76. package/dist/apps/shared/components/admin/diagnostics/UsageMeter.d.ts +26 -0
  77. package/dist/apps/shared/components/admin/diagnostics/UsageMeter.js +25 -0
  78. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +10 -0
  79. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +11 -0
  80. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +168 -0
  81. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +17 -0
  82. package/dist/apps/shared/components/admin/diagnostics/download.d.ts +9 -0
  83. package/dist/apps/shared/components/admin/diagnostics/download.js +23 -0
  84. package/dist/apps/shared/components/admin/diagnostics/format.d.ts +24 -0
  85. package/dist/apps/shared/components/admin/diagnostics/format.js +91 -0
  86. package/dist/apps/shared/components/admin/diagnostics/logClient.d.ts +85 -0
  87. package/dist/apps/shared/components/admin/diagnostics/logClient.js +173 -0
  88. package/dist/apps/shared/components/admin/diagnostics/logLines.d.ts +47 -0
  89. package/dist/apps/shared/components/admin/diagnostics/logLines.js +86 -0
  90. package/dist/apps/shared/components/admin/diagnostics/metricsHistory.d.ts +9 -0
  91. package/dist/apps/shared/components/admin/diagnostics/metricsHistory.js +19 -0
  92. package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.d.ts +12 -0
  93. package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.js +30 -0
  94. package/dist/apps/shared/components/admin/diagnostics/useLogStream.d.ts +37 -0
  95. package/dist/apps/shared/components/admin/diagnostics/useLogStream.js +79 -0
  96. package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.d.ts +20 -0
  97. package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.js +69 -0
  98. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  99. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  100. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +1 -2
  102. package/dist/apps/shared/components/mail/layout/MailShell.js +1 -2
  103. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -2
  104. package/dist/apps/shared/components/tasks/layout/TasksShell.js +1 -2
  105. package/dist/apps/shared/navigation/index.d.ts +11 -0
  106. package/dist/apps/shared/navigation/index.js +26 -2
  107. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** The levels of the server's logger (Winston's npm levels), most severe first. */
7
+ export const LOG_LEVELS = ["error", "warn", "info", "http", "verbose", "debug", "silly"] as const;
8
+
9
+ export type LogLevel = (typeof LOG_LEVELS)[number];
10
+
11
+ /** One line of the server's log stream. */
12
+ export interface LogEntry {
13
+ /** Counts up from 1 in the order lines arrived; unique for the life of the stream. */
14
+ id: number;
15
+ /** When this page received it (`Date.now()`). */
16
+ receivedAt: number;
17
+ /** The level as the server wrote it (lower-cased, without colour codes); empty when it wrote none. */
18
+ level: string;
19
+ message: string;
20
+ /** As the server wrote it, when it did. */
21
+ timestamp?: string;
22
+ /** Every other field the server logged with the line (`label`, `service`, `stack`, ...). */
23
+ extra: Record<string, unknown>;
24
+ /** The frame as one line of JSON: what the `.ndjson` download holds. */
25
+ json: string;
26
+ /** Whether the frame was JSON. A frame that was not is shown as it came, with no level. */
27
+ structured: boolean;
28
+ }
29
+
30
+ /** What a frame is, when it is the server's own control message and not a log line. */
31
+ export interface ControlFrame {
32
+ control: true;
33
+ success: boolean;
34
+ data: unknown;
35
+ }
36
+
37
+ /** Colour codes a logger writes around a level (`\u001b[32minfo\u001b[39m`). */
38
+ // eslint-disable-next-line no-control-regex
39
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
40
+
41
+ export const stripAnsi = (text: string): string => text.replace(ANSI_PATTERN, "");
42
+
43
+ /** A field's value as text: a string as it is, nothing as empty, anything else as JSON. */
44
+ function textOf(value: unknown): string {
45
+ if (typeof value === "string") {
46
+ return stripAnsi(value);
47
+ }
48
+ return value === undefined ? "" : JSON.stringify(value);
49
+ }
50
+
51
+ /**
52
+ * Reads one frame of the log stream. It is the control message (`{"id":0,"type":"SUBSCRIBED","success":true,...}`), a Winston
53
+ * `info` object (the JSON of `level`, `message` and whatever else was logged with them), or - since the server's logger can write
54
+ * anything - text that is not JSON at all. Whatever it is, the frame is never refused: a missing field is left empty.
55
+ */
56
+ export function parseLogFrame(raw: string, id: number, receivedAt: number): LogEntry | ControlFrame {
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch {
61
+ parsed = undefined;
62
+ }
63
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
64
+ return {
65
+ id,
66
+ receivedAt,
67
+ level: "",
68
+ message: stripAnsi(raw),
69
+ extra: {},
70
+ json: JSON.stringify({ raw }),
71
+ structured: false,
72
+ };
73
+ }
74
+ const frame = parsed as Record<string, unknown>;
75
+ if (frame.type === "SUBSCRIBED" && frame.level === undefined) {
76
+ return { control: true, success: frame.success !== false, data: frame.data };
77
+ }
78
+ const { level, message, timestamp, ...extra } = frame;
79
+ return {
80
+ id,
81
+ receivedAt,
82
+ level: textOf(level).toLowerCase(),
83
+ message: textOf(message),
84
+ timestamp: typeof timestamp === "string" ? timestamp : undefined,
85
+ extra,
86
+ json: JSON.stringify(frame),
87
+ structured: true,
88
+ };
89
+ }
90
+
91
+ export const isControlFrame = (frame: LogEntry | ControlFrame): frame is ControlFrame => "control" in frame;
92
+
93
+ /** When a line happened: what the server stamped it with, else when this page received it. */
94
+ export function entryTime(entry: LogEntry): string {
95
+ return entry.timestamp ?? new Date(entry.receivedAt).toISOString();
96
+ }
97
+
98
+ /** `<timestamp> <LEVEL> <message>`: the line of a `.log` download. A line with no level has none written. */
99
+ export function formatLogLine(entry: LogEntry): string {
100
+ return [entryTime(entry), entry.level.toUpperCase(), entry.message].filter((part) => part !== "").join(" ");
101
+ }
102
+
103
+ /** The text of a `.log` download: one formatted line for each entry. */
104
+ export function entriesToLog(entries: LogEntry[]): string {
105
+ return entries.map((entry) => formatLogLine(entry).replace(/\r?\n/g, " ↵ ")).join("\n") + "\n";
106
+ }
107
+
108
+ /** The text of an `.ndjson` download: each frame as one line of JSON. */
109
+ export function entriesToNdjson(entries: LogEntry[]): string {
110
+ return entries.map((entry) => entry.json).join("\n") + "\n";
111
+ }
112
+
113
+ /** Whether `entry` is one of `levels`. A line with no known level (text that was not JSON, an unusual level) is always kept. */
114
+ export function levelVisible(entry: LogEntry, levels: ReadonlySet<string>): boolean {
115
+ return !(LOG_LEVELS as readonly string[]).includes(entry.level) || levels.has(entry.level);
116
+ }
117
+
118
+ /** Whether `entry` contains `needle` (already lower-cased) in its time, level, message or other fields. */
119
+ export function entryMatches(entry: LogEntry, needle: string): boolean {
120
+ if (needle === "") {
121
+ return true;
122
+ }
123
+ const extra = Object.keys(entry.extra).length > 0 ? JSON.stringify(entry.extra) : "";
124
+ return `${formatLogLine(entry)} ${extra}`.toLowerCase().includes(needle);
125
+ }
@@ -0,0 +1,28 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
6
+
7
+ /** How often the System tab asks the server for a new sample. */
8
+ export const METRICS_POLL_INTERVAL_MS = 5000;
9
+
10
+ /** How many samples the System tab keeps: the last five minutes at `METRICS_POLL_INTERVAL_MS`. */
11
+ export const METRICS_HISTORY_SIZE = 60;
12
+
13
+ /** `history` with `sample` added, the oldest samples dropped beyond `METRICS_HISTORY_SIZE`. */
14
+ export function appendSample(history: DiagnosticsMetrics[], sample: DiagnosticsMetrics): DiagnosticsMetrics[] {
15
+ return [...history, sample].slice(-METRICS_HISTORY_SIZE);
16
+ }
17
+
18
+ /** The values `pick` finds in each sample, in order. A sample it finds nothing in (a node with no stats yet, say) is skipped. */
19
+ export function seriesOf(history: DiagnosticsMetrics[], pick: (sample: DiagnosticsMetrics) => number | undefined): number[] {
20
+ const values: number[] = [];
21
+ for (const sample of history) {
22
+ const value = pick(sample);
23
+ if (typeof value === "number" && Number.isFinite(value)) {
24
+ values.push(value);
25
+ }
26
+ }
27
+ return values;
28
+ }
@@ -0,0 +1,44 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import { describeError } from "./format.js";
7
+
8
+ export interface DiagnosticsResource<T> {
9
+ /** What was last loaded. It stays while a reload is under way, and after a reload that failed. */
10
+ data: T | undefined;
11
+ /** Why the last load failed. */
12
+ error: string | undefined;
13
+ loading: boolean;
14
+ }
15
+
16
+ /**
17
+ * Loads `load()` when the component mounts and again whenever `reloadKey` changes (the Refresh button). It is never polled.
18
+ * The answer to a load that a newer one (or an unmount) has overtaken is dropped.
19
+ */
20
+ export function useDiagnosticsResource<T>(load: () => Promise<T>, reloadKey: number, failure: string): DiagnosticsResource<T> {
21
+ const [state, setState] = useState<DiagnosticsResource<T>>({ data: undefined, error: undefined, loading: true });
22
+
23
+ useEffect(() => {
24
+ let cancelled = false;
25
+ setState((previous) => ({ ...previous, error: undefined, loading: true }));
26
+ load().then(
27
+ (data) => {
28
+ if (!cancelled) {
29
+ setState({ data, error: undefined, loading: false });
30
+ }
31
+ },
32
+ (err: unknown) => {
33
+ if (!cancelled) {
34
+ setState((previous) => ({ ...previous, error: describeError(err, failure), loading: false }));
35
+ }
36
+ }
37
+ );
38
+ return () => {
39
+ cancelled = true;
40
+ };
41
+ }, [reloadKey]);
42
+
43
+ return state;
44
+ }
@@ -0,0 +1,122 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { LogClient, LogClientOptions, LogStreamStatus } from "./logClient.js";
7
+ import type { LogEntry } from "./logLines.js";
8
+ import { LogStore } from "./LogStore.js";
9
+
10
+ /** Lines arrive in bursts; the view is redrawn at most this often, however many came. */
11
+ export const LOG_FLUSH_MS = 100;
12
+
13
+ /** What a capture has recorded, without the lines (which can be tens of thousands: `LogStream.captureEntries()` has them). */
14
+ export interface CaptureSummary {
15
+ active: boolean;
16
+ startedAt: number;
17
+ stoppedAt: number | undefined;
18
+ count: number;
19
+ missed: number;
20
+ }
21
+
22
+ export interface LogStream {
23
+ status: LogStreamStatus;
24
+ /** Why the stream is in the `error` state. */
25
+ error: string | undefined;
26
+ /** Whether the stream is on (connecting, live or trying again), as opposed to stopped or refused. */
27
+ running: boolean;
28
+ /** The display buffer: the most recent lines, oldest first. */
29
+ entries: LogEntry[];
30
+ /** How many older lines the display buffer has dropped. */
31
+ dropped: number;
32
+ capture: CaptureSummary | undefined;
33
+ start: () => void;
34
+ stop: () => void;
35
+ /** Empties the display buffer (not a capture). */
36
+ clear: () => void;
37
+ startCapture: () => void;
38
+ stopCapture: () => void;
39
+ /** Every line of the capture, for a download. */
40
+ captureEntries: () => LogEntry[];
41
+ }
42
+
43
+ interface Snapshot {
44
+ entries: LogEntry[];
45
+ dropped: number;
46
+ capture: CaptureSummary | undefined;
47
+ }
48
+
49
+ function snapshotOf(store: LogStore): Snapshot {
50
+ const capture = store.capture;
51
+ return {
52
+ entries: store.entries.slice(),
53
+ dropped: store.dropped,
54
+ capture: capture && {
55
+ active: capture.active,
56
+ startedAt: capture.startedAt,
57
+ stoppedAt: capture.stoppedAt,
58
+ count: capture.entries.length,
59
+ missed: capture.missed,
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The live log stream of the server for the log view: a `LogClient`, and a `LogStore` holding the lines and any capture. Nothing
66
+ * connects until `start()`. The stream is closed, and the redraw timer cleared, when the component unmounts.
67
+ */
68
+ export function useLogStream(options: LogClientOptions = {}): LogStream {
69
+ const [client] = useState(() => new LogClient(options));
70
+ const [store] = useState(() => new LogStore());
71
+ const [connection, setConnection] = useState<{ status: LogStreamStatus; error: string | undefined }>({
72
+ status: client.status,
73
+ error: undefined,
74
+ });
75
+ const [snapshot, setSnapshot] = useState<Snapshot>(() => snapshotOf(store));
76
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
77
+
78
+ const flush = useCallback(() => {
79
+ clearTimeout(timer.current);
80
+ timer.current = undefined;
81
+ setSnapshot(snapshotOf(store));
82
+ }, [store]);
83
+
84
+ useEffect(() => {
85
+ const stopEntries = client.onEntry((entry) => {
86
+ store.add(entry);
87
+ timer.current ??= setTimeout(flush, LOG_FLUSH_MS);
88
+ });
89
+ const stopStatus = client.onStatus((status, error) => setConnection({ status, error }));
90
+ return () => {
91
+ stopEntries();
92
+ stopStatus();
93
+ client.stop();
94
+ clearTimeout(timer.current);
95
+ timer.current = undefined;
96
+ };
97
+ }, [client, store, flush]);
98
+
99
+ return {
100
+ status: connection.status,
101
+ error: connection.error,
102
+ running: connection.status === "connecting" || connection.status === "live" || connection.status === "reconnecting",
103
+ entries: snapshot.entries,
104
+ dropped: snapshot.dropped,
105
+ capture: snapshot.capture,
106
+ start: () => client.start(),
107
+ stop: () => client.stop(),
108
+ clear: () => {
109
+ store.clear();
110
+ flush();
111
+ },
112
+ startCapture: () => {
113
+ store.startCapture(Date.now());
114
+ flush();
115
+ },
116
+ stopCapture: () => {
117
+ store.stopCapture(Date.now());
118
+ flush();
119
+ },
120
+ captureEntries: () => store.capture?.entries ?? [],
121
+ };
122
+ }
@@ -0,0 +1,84 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import { DiagnosticsMetrics, getDiagnosticsMetrics } from "./diagnosticsApi.js";
7
+ import { describeError, isPermissionError } from "./format.js";
8
+ import { appendSample, METRICS_POLL_INTERVAL_MS } from "./metricsHistory.js";
9
+
10
+ export interface MetricsPolling {
11
+ /** The last `METRICS_HISTORY_SIZE` samples, oldest first. */
12
+ history: DiagnosticsMetrics[];
13
+ /** Why the last request failed, until one succeeds. */
14
+ error: string | undefined;
15
+ /** Whether nothing has been answered yet. */
16
+ loading: boolean;
17
+ /** Whether polling is stopped by the viewer (Pause), or because the server refused the viewer. */
18
+ paused: boolean;
19
+ setPaused: (paused: boolean) => void;
20
+ }
21
+
22
+ /**
23
+ * Samples `getDiagnosticsMetrics()` every `METRICS_POLL_INTERVAL_MS` while `enabled` and not paused, keeping the last
24
+ * `METRICS_HISTORY_SIZE`. A request is only ever made when the previous one has answered, none is made while the page is
25
+ * hidden (one is made at once when it is shown again), and polling stops when the component unmounts. A refusal (401/403)
26
+ * pauses polling, since asking again would only be refused again; Resume tries once more. Any other failure is shown
27
+ * and polling carries on, so a blip on the server does not need the viewer to do anything.
28
+ */
29
+ export function useMetricsPolling(enabled: boolean): MetricsPolling {
30
+ const [history, setHistory] = useState<DiagnosticsMetrics[]>([]);
31
+ const [error, setError] = useState<string | undefined>();
32
+ const [loading, setLoading] = useState(true);
33
+ const [paused, setPaused] = useState(false);
34
+
35
+ useEffect(() => {
36
+ if (!enabled || paused) {
37
+ return;
38
+ }
39
+ let cancelled = false;
40
+ let inFlight = false;
41
+
42
+ async function sample() {
43
+ if (inFlight || document.hidden) {
44
+ return;
45
+ }
46
+ inFlight = true;
47
+ try {
48
+ const next = await getDiagnosticsMetrics();
49
+ if (!cancelled) {
50
+ setHistory((previous) => appendSample(previous, next));
51
+ setError(undefined);
52
+ }
53
+ } catch (err) {
54
+ if (!cancelled) {
55
+ setError(describeError(err, "Could not read the server's metrics."));
56
+ if (isPermissionError(err)) {
57
+ setPaused(true);
58
+ }
59
+ }
60
+ } finally {
61
+ inFlight = false;
62
+ if (!cancelled) {
63
+ setLoading(false);
64
+ }
65
+ }
66
+ }
67
+
68
+ void sample();
69
+ const timer = setInterval(() => void sample(), METRICS_POLL_INTERVAL_MS);
70
+ const onVisibilityChange = () => {
71
+ if (!document.hidden) {
72
+ void sample();
73
+ }
74
+ };
75
+ document.addEventListener("visibilitychange", onVisibilityChange);
76
+ return () => {
77
+ cancelled = true;
78
+ clearInterval(timer);
79
+ document.removeEventListener("visibilitychange", onVisibilityChange);
80
+ };
81
+ }, [enabled, paused]);
82
+
83
+ return { history, error, loading, paused, setPaused };
84
+ }
@@ -8,6 +8,7 @@ import { useRouter } from "@rapidrest/react/client";
8
8
  import {
9
9
  HiOutlineClipboardDocumentList,
10
10
  HiOutlineClock,
11
+ HiOutlineCpuChip,
11
12
  HiOutlineDocumentArrowDown,
12
13
  HiOutlineDocumentCheck,
13
14
  HiOutlineGlobeAlt,
@@ -61,6 +62,7 @@ export type AdminSection =
61
62
  | "setup"
62
63
  | "dataRequests"
63
64
  | "plugins"
65
+ | "diagnostics"
64
66
  | "branding"
65
67
  | "signingCertificates";
66
68
 
@@ -144,6 +146,7 @@ const NAV_ITEMS: NavItem[] = [
144
146
  icon: HiOutlineDocumentCheck,
145
147
  },
146
148
  { id: "plugins", href: "/admin/plugins", label: "Plugins", icon: HiOutlinePuzzlePiece },
149
+ { id: "diagnostics", href: "/admin/diagnostics", label: "Diagnostics", icon: HiOutlineCpuChip },
147
150
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
148
151
  ];
149
152
 
@@ -9,7 +9,7 @@ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shar
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
11
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
- import { useLocation } from "@rapidrest/react/client";
12
+ import { useLocation } from "../../../navigation/index.js";
13
13
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
14
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
15
15
 
@@ -10,8 +10,7 @@ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shar
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
12
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
- import { useLocation } from "@rapidrest/react/client";
14
- import { useNavigate } from "../../../navigation/index.js";
13
+ import { useLocation, useNavigate } from "../../../navigation/index.js";
15
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
15
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
17
16
 
@@ -10,8 +10,7 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
11
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
12
  import FloatingActionButton from "../../layout/FloatingActionButton.js";
13
- import { useLocation } from "@rapidrest/react/client";
14
- import { useNavigate } from "../../../navigation/index.js";
13
+ import { useLocation, useNavigate } from "../../../navigation/index.js";
15
14
  import KeyEnrollmentGate from "../../layout/KeyEnrollmentGate.js";
16
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
17
16
  import { prefetchComposeWindow, useCompose } from "../compose/ComposeContext.js";
@@ -10,8 +10,7 @@ import { Mailbox, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
12
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
- import { useLocation } from "@rapidrest/react/client";
14
- import { useNavigate } from "../../../navigation/index.js";
13
+ import { useLocation, useNavigate } from "../../../navigation/index.js";
15
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
15
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
17
16
  import { mergePluginNavItems, PluginNav } from "../../../plugins/pluginNav.js";
@@ -10,8 +10,7 @@ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shar
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
12
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
- import { useLocation } from "@rapidrest/react/client";
14
- import { useNavigate } from "../../../navigation/index.js";
13
+ import { useLocation, useNavigate } from "../../../navigation/index.js";
15
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
16
15
  import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
17
16
 
@@ -7,8 +7,8 @@
7
7
  * route, the app's `_shell.tsx` as the persistent frame - see `apps/www/_shell.tsx`); this is the one thing on top of it: a
8
8
  * `navigate()` that keeps the page when only the query string changes.
9
9
  */
10
- import { useCallback, useRef } from "react";
11
- import { useRouter } from "@rapidrest/react/client";
10
+ import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
11
+ import { useLocation as useRouterLocation, useRouter } from "@rapidrest/react/client";
12
12
 
13
13
  export interface NavigateOptions {
14
14
  /** Replaces the current history entry instead of adding one (a redirect, or a state change that isn't a "place"). */
@@ -41,3 +41,30 @@ export function useNavigate(): NavigateFn {
41
41
  void go(href, { replace: options.replace, shallow });
42
42
  }, []);
43
43
  }
44
+
45
+ function subscribeToHistory(onChange: () => void): () => void {
46
+ window.addEventListener("popstate", onChange);
47
+ return () => window.removeEventListener("popstate", onChange);
48
+ }
49
+
50
+ // Joined, not added: a test's stand-in for `window.location` may have no `search`, and `undefined + undefined` is `NaN`.
51
+ const currentAddress = (): string => [window.location.pathname, window.location.search, window.location.hash].join("");
52
+ const noAddress = (): string => "";
53
+
54
+ /**
55
+ * Where the page is, as `{ pathname, search, hash }`: the router's location when the page is under a router, else the browser's.
56
+ * A page a plugin renders has no router (`router = false`), where `@rapidrest/react`'s `useLocation()` is empty and the query a
57
+ * shell reads its mailbox from (`?mailboxUid=`) would be lost. Without one, the address is the browser's - and empty on the
58
+ * server and while hydrating, so the page hydrates as it was rendered and then follows the address.
59
+ */
60
+ export function useLocation(): { pathname: string; search: string; hash: string } {
61
+ const routed = useRouterLocation();
62
+ const own = useSyncExternalStore(subscribeToHistory, currentAddress, noAddress);
63
+ const ownLocation = useMemo(() => {
64
+ // Split by hand, not with `URL`: a page that stubs the global `URL` (for `createObjectURL`) must still render.
65
+ const [beforeHash, ...fragment] = own.split("#");
66
+ const [pathname, ...query] = beforeHash.split("?");
67
+ return { pathname, search: query.length ? `?${query.join("?")}` : "", hash: fragment.length ? `#${fragment.join("#")}` : "" };
68
+ }, [own]);
69
+ return routed.pathname !== "" ? routed : ownLocation;
70
+ }
@@ -0,0 +1,3 @@
1
+ import React from "react";
2
+ import { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
3
+ export default function DiagnosticsPage(props: Omit<AdminShellProps, "active">): React.JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
3
+ import DiagnosticsManager from "../../shared/components/admin/diagnostics/DiagnosticsManager.js";
4
+ export default function DiagnosticsPage(props) {
5
+ return (_jsx(AdminShell, { ...props, active: "diagnostics", children: _jsx(DiagnosticsManager, {}) }));
6
+ }
@@ -0,0 +1,10 @@
1
+ import React, { ReactNode } from "react";
2
+ import type { IconType } from "react-icons";
3
+ export type BadgeTone = "success" | "warning" | "danger" | "info" | "neutral";
4
+ export interface BadgeProps {
5
+ tone?: BadgeTone;
6
+ icon?: IconType;
7
+ children: ReactNode;
8
+ }
9
+ /** A small pill: an optional icon, then a word or two. */
10
+ export default function Badge({ tone, icon: Icon, children }: BadgeProps): React.JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** The tint behind a badge and the colour of its icon. The words stay in the normal text colour, and every tone
3
+ * that means something (good, warning, bad) is also worded, so a badge never relies on its colour alone. */
4
+ const TONES = {
5
+ success: { background: "bg-success/15", icon: "text-success" },
6
+ warning: { background: "bg-warning/25", icon: "text-warning" },
7
+ danger: { background: "bg-danger/15", icon: "text-danger" },
8
+ info: { background: "bg-primary/15", icon: "text-primary" },
9
+ neutral: { background: "bg-surface-alt", icon: "text-text-muted" },
10
+ };
11
+ /** A small pill: an optional icon, then a word or two. */
12
+ export default function Badge({ tone = "neutral", icon: Icon, children }) {
13
+ const classes = TONES[tone];
14
+ return (_jsxs("span", { className: `inline-flex items-center gap-1 rounded-pill border border-border px-2 py-0.5 text-xs font-medium text-text whitespace-nowrap ${classes.background}`, children: [Icon && _jsx(Icon, { size: 14, "aria-hidden": "true", className: classes.icon }), children] }));
15
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import type { DiagnosticsComponent, DiagnosticsContainer } from "./diagnosticsApi.js";
3
+ /** The container to describe a component by: the one running the image tag the server calls its version, else the first. */
4
+ export declare function mainContainer(component: DiagnosticsComponent): DiagnosticsContainer | undefined;
5
+ /** `image` with its tag, unless the server already wrote the tag into the image. */
6
+ export declare function imageReference(container: DiagnosticsContainer): string;
7
+ export interface ComponentsTableProps {
8
+ components: DiagnosticsComponent[];
9
+ kubernetes: {
10
+ available: boolean;
11
+ reason?: string;
12
+ };
13
+ }
14
+ /** The other containers of the install (mail transport, databases, the spam and virus filters, the TURN relay) and how they are doing. */
15
+ export default function ComponentsTable({ components, kubernetes }: ComponentsTableProps): React.JSX.Element;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiOutlineCheckCircle, HiOutlineExclamationTriangle, HiOutlineMinusCircle, HiOutlineQuestionMarkCircle } from "react-icons/hi2";
3
+ import Badge from "./Badge.js";
4
+ import KubernetesNotice from "./KubernetesNotice.js";
5
+ import { formatDateTime, NO_VALUE, shortDigest } from "./format.js";
6
+ const STATUS = {
7
+ running: { label: "Running", tone: "success", icon: HiOutlineCheckCircle },
8
+ "not-ready": { label: "Not ready", tone: "warning", icon: HiOutlineExclamationTriangle },
9
+ missing: { label: "Not found", tone: "neutral", icon: HiOutlineMinusCircle },
10
+ unknown: { label: "Unknown", tone: "neutral", icon: HiOutlineQuestionMarkCircle },
11
+ };
12
+ /** The container to describe a component by: the one running the image tag the server calls its version, else the first. */
13
+ export function mainContainer(component) {
14
+ const containers = component.pods.flatMap((pod) => pod.containers);
15
+ return containers.find((container) => container.tag !== undefined && container.tag === component.version) ?? containers[0];
16
+ }
17
+ /** `image` with its tag, unless the server already wrote the tag into the image. */
18
+ export function imageReference(container) {
19
+ return container.tag && !container.image.endsWith(`:${container.tag}`) ? `${container.image}:${container.tag}` : container.image;
20
+ }
21
+ const HEADINGS = ["Component", "Status", "Version", "Image", "Digest", "Restarts", "Node"];
22
+ /** The other containers of the install (mail transport, databases, the spam and virus filters, the TURN relay) and how they are doing. */
23
+ export default function ComponentsTable({ components, kubernetes }) {
24
+ return (_jsxs("section", { "aria-labelledby": "diagnostics-components-heading", className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: "diagnostics-components-heading", className: "text-base font-bold uppercase tracking-wide mb-3", children: "Other containers" }), !kubernetes.available ? (_jsx(KubernetesNotice, { reason: kubernetes.reason })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: HEADINGS.map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: components.map((component) => (_jsx(ComponentRow, { component: component }, component.component))) })] }) }))] }));
25
+ }
26
+ function ComponentRow({ component }) {
27
+ const status = STATUS[component.status] ?? STATUS.unknown;
28
+ const main = mainContainer(component);
29
+ const restarts = component.pods.reduce((total, pod) => total + pod.restarts, 0);
30
+ const nodes = [...new Set(component.pods.map((pod) => pod.node).filter(Boolean))];
31
+ return (_jsxs("tr", { className: "align-top", children: [_jsxs("td", { className: "py-2 px-2.5 border-b border-border", children: [_jsx("div", { className: "font-medium", children: component.component }), component.pods.length > 0 && (_jsxs("details", { className: "text-xs text-text-muted", children: [_jsxs("summary", { className: "cursor-pointer", children: [component.pods.length, " ", component.pods.length === 1 ? "pod" : "pods"] }), _jsx("ul", { className: "mt-1 space-y-2", children: component.pods.map((pod) => (_jsx(PodDetail, { pod: pod }, pod.name))) })] }))] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border", children: _jsx(Badge, { tone: status.tone, icon: status.icon, children: status.label }) }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono", children: component.version ?? NO_VALUE }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: main?.image ?? NO_VALUE }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono", title: main?.digest, children: shortDigest(main?.digest) }), _jsx("td", { className: "py-2 px-2.5 border-b border-border tabular-nums", children: component.pods.length > 0 ? restarts : NO_VALUE }), _jsx("td", { className: "py-2 px-2.5 border-b border-border", children: nodes.length > 0 ? nodes.join(", ") : NO_VALUE })] }));
32
+ }
33
+ function PodDetail({ pod }) {
34
+ return (_jsxs("li", { className: "rounded-sm border border-border p-2", children: [_jsx("div", { className: "font-mono text-text break-all", children: pod.name }), _jsxs("div", { children: [pod.phase, " \u00B7 ", pod.ready ? "ready" : "not ready", " \u00B7 ", pod.restarts, " ", pod.restarts === 1 ? "restart" : "restarts", pod.node ? ` · on ${pod.node}` : "", pod.startedAt ? ` · started ${formatDateTime(pod.startedAt)}` : ""] }), _jsx("ul", { className: "mt-1", children: pod.containers.map((container) => (_jsxs("li", { className: "font-mono break-all", children: [container.name, ": ", imageReference(container), container.digest ? ` @${shortDigest(container.digest)}` : "", " \u00B7 ", container.ready ? "ready" : "not ready", " \u00B7", " ", container.restartCount, " ", container.restartCount === 1 ? "restart" : "restarts"] }, container.name))) })] }));
35
+ }