@rapidmx/web-client 0.17.2 → 0.19.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/apps/admin/diagnostics/index.tsx +15 -0
- package/apps/shared/components/admin/diagnostics/Badge.tsx +37 -0
- package/apps/shared/components/admin/diagnostics/ComponentsTable.tsx +131 -0
- package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +173 -0
- package/apps/shared/components/admin/diagnostics/HostCard.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +136 -0
- package/apps/shared/components/admin/diagnostics/KubernetesNotice.tsx +25 -0
- package/apps/shared/components/admin/diagnostics/LogStore.ts +75 -0
- package/apps/shared/components/admin/diagnostics/LogViewer.tsx +87 -0
- package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +235 -0
- package/apps/shared/components/admin/diagnostics/MetricTile.tsx +43 -0
- package/apps/shared/components/admin/diagnostics/NamespaceCard.tsx +95 -0
- package/apps/shared/components/admin/diagnostics/PackagesTable.tsx +107 -0
- package/apps/shared/components/admin/diagnostics/ProcessCard.tsx +70 -0
- package/apps/shared/components/admin/diagnostics/PvcTable.tsx +79 -0
- package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +110 -0
- package/apps/shared/components/admin/diagnostics/ServerVersionCard.tsx +37 -0
- package/apps/shared/components/admin/diagnostics/Sparkline.tsx +79 -0
- package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +81 -0
- package/apps/shared/components/admin/diagnostics/UsageMeter.tsx +73 -0
- package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +36 -0
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +197 -0
- package/apps/shared/components/admin/diagnostics/download.ts +28 -0
- package/apps/shared/components/admin/diagnostics/format.ts +106 -0
- package/apps/shared/components/admin/diagnostics/logClient.ts +231 -0
- package/apps/shared/components/admin/diagnostics/logLines.ts +125 -0
- package/apps/shared/components/admin/diagnostics/metricsHistory.ts +28 -0
- package/apps/shared/components/admin/diagnostics/useDiagnosticsResource.ts +44 -0
- package/apps/shared/components/admin/diagnostics/useLogStream.ts +122 -0
- package/apps/shared/components/admin/diagnostics/useMetricsPolling.ts +84 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +87 -12
- package/dist/apps/admin/diagnostics/index.d.ts +3 -0
- package/dist/apps/admin/diagnostics/index.js +6 -0
- package/dist/apps/shared/components/admin/diagnostics/Badge.d.ts +10 -0
- package/dist/apps/shared/components/admin/diagnostics/Badge.js +15 -0
- package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.d.ts +15 -0
- package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.js +35 -0
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +17 -0
- package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +111 -0
- package/dist/apps/shared/components/admin/diagnostics/HostCard.d.ts +13 -0
- package/dist/apps/shared/components/admin/diagnostics/HostCard.js +33 -0
- package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.d.ts +26 -0
- package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +60 -0
- package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.d.ts +8 -0
- package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.js +9 -0
- package/dist/apps/shared/components/admin/diagnostics/LogStore.d.ts +37 -0
- package/dist/apps/shared/components/admin/diagnostics/LogStore.js +50 -0
- package/dist/apps/shared/components/admin/diagnostics/LogViewer.d.ts +24 -0
- package/dist/apps/shared/components/admin/diagnostics/LogViewer.js +47 -0
- package/dist/apps/shared/components/admin/diagnostics/LogsPanel.d.ts +13 -0
- package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +63 -0
- package/dist/apps/shared/components/admin/diagnostics/MetricTile.d.ts +21 -0
- package/dist/apps/shared/components/admin/diagnostics/MetricTile.js +7 -0
- package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.d.ts +13 -0
- package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.js +15 -0
- package/dist/apps/shared/components/admin/diagnostics/PackagesTable.d.ts +8 -0
- package/dist/apps/shared/components/admin/diagnostics/PackagesTable.js +31 -0
- package/dist/apps/shared/components/admin/diagnostics/ProcessCard.d.ts +8 -0
- package/dist/apps/shared/components/admin/diagnostics/ProcessCard.js +18 -0
- package/dist/apps/shared/components/admin/diagnostics/PvcTable.d.ts +6 -0
- package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +18 -0
- package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.d.ts +7 -0
- package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +28 -0
- package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.d.ts +6 -0
- package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.js +17 -0
- package/dist/apps/shared/components/admin/diagnostics/Sparkline.d.ts +22 -0
- package/dist/apps/shared/components/admin/diagnostics/Sparkline.js +34 -0
- package/dist/apps/shared/components/admin/diagnostics/SystemPanel.d.ts +6 -0
- package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +26 -0
- package/dist/apps/shared/components/admin/diagnostics/UsageMeter.d.ts +26 -0
- package/dist/apps/shared/components/admin/diagnostics/UsageMeter.js +25 -0
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +10 -0
- package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +11 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +168 -0
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +17 -0
- package/dist/apps/shared/components/admin/diagnostics/download.d.ts +9 -0
- package/dist/apps/shared/components/admin/diagnostics/download.js +23 -0
- package/dist/apps/shared/components/admin/diagnostics/format.d.ts +24 -0
- package/dist/apps/shared/components/admin/diagnostics/format.js +91 -0
- package/dist/apps/shared/components/admin/diagnostics/logClient.d.ts +85 -0
- package/dist/apps/shared/components/admin/diagnostics/logClient.js +173 -0
- package/dist/apps/shared/components/admin/diagnostics/logLines.d.ts +47 -0
- package/dist/apps/shared/components/admin/diagnostics/logLines.js +86 -0
- package/dist/apps/shared/components/admin/diagnostics/metricsHistory.d.ts +9 -0
- package/dist/apps/shared/components/admin/diagnostics/metricsHistory.js +19 -0
- package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.d.ts +12 -0
- package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.js +30 -0
- package/dist/apps/shared/components/admin/diagnostics/useLogStream.d.ts +37 -0
- package/dist/apps/shared/components/admin/diagnostics/useLogStream.js +79 -0
- package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.d.ts +20 -0
- package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.js +69 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +61 -13
- package/package.json +2 -2
|
@@ -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
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
planPluginChange,
|
|
16
16
|
Plugin,
|
|
17
17
|
PluginChangePlan,
|
|
18
|
+
PluginConfiguredSetting,
|
|
18
19
|
PluginInstanceStatus,
|
|
19
20
|
PluginNamespace,
|
|
20
21
|
PluginPurgeInfo,
|
|
@@ -1315,13 +1316,39 @@ function suggestedHost(definition: PluginSettingDefinition, saved: PluginSetting
|
|
|
1315
1316
|
return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
|
|
1316
1317
|
}
|
|
1317
1318
|
|
|
1318
|
-
/**
|
|
1319
|
-
|
|
1320
|
-
|
|
1319
|
+
/** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
|
|
1320
|
+
function configuredValue(definition: PluginSettingDefinition, configured: PluginConfiguredSetting): PluginSettingValue | "" {
|
|
1321
|
+
if (definition.type === "boolean") {
|
|
1322
|
+
return configured.value === true;
|
|
1323
|
+
}
|
|
1324
|
+
return configured.value === undefined ? "" : String(configured.value);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
|
|
1328
|
+
function isUnsaved(saved: PluginSettingValue | undefined): boolean {
|
|
1329
|
+
return saved === undefined || saved === "";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
|
|
1333
|
+
function hostSuggestion(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined, configured: PluginConfiguredSetting | undefined): string | undefined {
|
|
1334
|
+
return configured ? undefined : suggestedHost(definition, saved);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
|
|
1338
|
+
* deployment configures; else the setting's own default - or, for a required select with none of those, its first
|
|
1339
|
+
* option. Kept as a string for text inputs. */
|
|
1340
|
+
function initialValue(
|
|
1341
|
+
definition: PluginSettingDefinition,
|
|
1342
|
+
saved: PluginSettingValue | undefined,
|
|
1343
|
+
configured?: PluginConfiguredSetting,
|
|
1344
|
+
): PluginSettingValue | "" {
|
|
1345
|
+
if (configured && isUnsaved(saved)) {
|
|
1346
|
+
return configuredValue(definition, configured);
|
|
1347
|
+
}
|
|
1321
1348
|
if (needsSelection(definition, saved)) {
|
|
1322
1349
|
return definition.options![0].value;
|
|
1323
1350
|
}
|
|
1324
|
-
const suggestion =
|
|
1351
|
+
const suggestion = hostSuggestion(definition, saved, configured);
|
|
1325
1352
|
if (suggestion !== undefined) {
|
|
1326
1353
|
return suggestion;
|
|
1327
1354
|
}
|
|
@@ -1335,29 +1362,53 @@ function initialValue(definition: PluginSettingDefinition, saved: PluginSettingV
|
|
|
1335
1362
|
function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
|
|
1336
1363
|
// Only opened from the Settings button, which is only shown for a plugin that declares settings.
|
|
1337
1364
|
const definitions: PluginSettingDefinition[] = plugin.manifest.settings!;
|
|
1338
|
-
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
|
|
1365
|
+
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
|
|
1339
1366
|
const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
|
|
1367
|
+
// Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
|
|
1368
|
+
const [resetting, setResetting] = useState(false);
|
|
1340
1369
|
const [error, setError] = useState<string | null>(null);
|
|
1341
1370
|
const [busy, setBusy] = useState(false);
|
|
1342
1371
|
|
|
1372
|
+
/** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
|
|
1373
|
+
function reset() {
|
|
1374
|
+
setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
|
|
1375
|
+
setResetting(true);
|
|
1376
|
+
setError(null);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1343
1379
|
async function save(e: FormEvent) {
|
|
1344
1380
|
e.preventDefault();
|
|
1345
1381
|
// The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
|
|
1346
1382
|
// - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
|
|
1347
1383
|
// A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
|
|
1348
|
-
// form shows them.
|
|
1384
|
+
// form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
|
|
1385
|
+
// - a field that shows it is sent as nothing.
|
|
1349
1386
|
const settings: Record<string, PluginSettingValue | null> = {};
|
|
1350
1387
|
let changed = false;
|
|
1351
1388
|
for (const definition of definitions) {
|
|
1352
1389
|
const key: string = definition.key;
|
|
1390
|
+
const configured: PluginConfiguredSetting | undefined = plugin.configured?.[key];
|
|
1391
|
+
const saved: PluginSettingValue | undefined = plugin.settings[key];
|
|
1353
1392
|
const value = values[key];
|
|
1354
|
-
if (value ===
|
|
1355
|
-
|
|
1393
|
+
if (resetting && value === initialValue(definition, undefined, configured)) {
|
|
1394
|
+
// What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
|
|
1395
|
+
// A saved value that already is the default shown stays, since dropping it changes nothing.
|
|
1396
|
+
settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
|
|
1397
|
+
changed ||= settings[key] !== (saved ?? null);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
|
|
1401
|
+
settings[key] = saved ?? null;
|
|
1356
1402
|
continue;
|
|
1357
1403
|
}
|
|
1358
1404
|
changed = true;
|
|
1359
1405
|
// A number input only ever reports a valid number or an empty string.
|
|
1360
|
-
settings[key] =
|
|
1406
|
+
settings[key] =
|
|
1407
|
+
value === "" || (configured && !configured.secret && String(value) === String(configured.value))
|
|
1408
|
+
? null
|
|
1409
|
+
: definition.type === "number"
|
|
1410
|
+
? Number(value)
|
|
1411
|
+
: value;
|
|
1361
1412
|
}
|
|
1362
1413
|
if (!changed) {
|
|
1363
1414
|
onClose();
|
|
@@ -1383,14 +1434,22 @@ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose:
|
|
|
1383
1434
|
key={definition.key}
|
|
1384
1435
|
definition={definition}
|
|
1385
1436
|
value={values[definition.key]}
|
|
1437
|
+
configured={plugin.configured?.[definition.key]}
|
|
1386
1438
|
onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
|
|
1387
1439
|
/>
|
|
1388
1440
|
))}
|
|
1389
1441
|
{/* Kept in view at the bottom of the dialog while a long list of settings scrolls. The dialog scrolls
|
|
1390
1442
|
inside its padding, so the bar reaches past it to the dialog's edges. */}
|
|
1391
1443
|
<div className="sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3">
|
|
1392
|
-
<p className="text-xs text-text-muted">
|
|
1444
|
+
<p className="text-xs text-text-muted">
|
|
1445
|
+
{resetting
|
|
1446
|
+
? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
|
|
1447
|
+
: "Saving restarts the servers one at a time to apply the new settings."}
|
|
1448
|
+
</p>
|
|
1393
1449
|
<div className="flex gap-2 justify-end">
|
|
1450
|
+
<Button type="button" variant="secondary" className="!w-auto mr-auto" onClick={reset} disabled={busy}>
|
|
1451
|
+
Reset
|
|
1452
|
+
</Button>
|
|
1394
1453
|
<Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
|
|
1395
1454
|
Cancel
|
|
1396
1455
|
</Button>
|
|
@@ -1407,13 +1466,28 @@ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose:
|
|
|
1407
1466
|
function SettingField({
|
|
1408
1467
|
definition,
|
|
1409
1468
|
value,
|
|
1469
|
+
configured,
|
|
1410
1470
|
onChange,
|
|
1411
1471
|
}: {
|
|
1412
1472
|
definition: PluginSettingDefinition;
|
|
1413
1473
|
value: PluginSettingValue | "";
|
|
1474
|
+
configured?: PluginConfiguredSetting;
|
|
1414
1475
|
onChange: (value: PluginSettingValue | "") => void;
|
|
1415
1476
|
}) {
|
|
1416
|
-
|
|
1477
|
+
// What applies without a saved value; one saved here replaces it, and Reset goes back to it.
|
|
1478
|
+
const note = configured && (
|
|
1479
|
+
<span className="text-xs text-text-muted">
|
|
1480
|
+
This deployment's configuration provides {configured.secret ? "a value" : "the value shown"} unless one is saved here.
|
|
1481
|
+
</span>
|
|
1482
|
+
);
|
|
1483
|
+
// A secret's value never reaches the browser, so the field only says that it is set.
|
|
1484
|
+
const placeholder: string | undefined = configured?.secret ? "Set by this deployment" : undefined;
|
|
1485
|
+
const help = (
|
|
1486
|
+
<>
|
|
1487
|
+
{definition.help && <span className="text-xs text-text-muted">{definition.help}</span>}
|
|
1488
|
+
{note}
|
|
1489
|
+
</>
|
|
1490
|
+
);
|
|
1417
1491
|
if (definition.type === "boolean") {
|
|
1418
1492
|
// Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
|
|
1419
1493
|
return (
|
|
@@ -1445,7 +1519,8 @@ function SettingField({
|
|
|
1445
1519
|
type={definition.type === "number" ? "number" : "text"}
|
|
1446
1520
|
min={definition.min}
|
|
1447
1521
|
max={definition.max}
|
|
1448
|
-
required={definition.required}
|
|
1522
|
+
required={definition.required && !configured}
|
|
1523
|
+
placeholder={placeholder}
|
|
1449
1524
|
value={String(value)}
|
|
1450
1525
|
onChange={(e) => onChange(e.target.value)}
|
|
1451
1526
|
/>
|
|
@@ -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
|
+
}
|