@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,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { HiOutlineArrowDownTray, HiOutlineArrowPath } from "react-icons/hi2";
8
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
9
+ import { getDiagnosticsMetrics, getDiagnosticsRuntime, getDiagnosticsVersions } from "./diagnosticsApi.js";
10
+ import { saveTextFile, timestampedFilename } from "./download.js";
11
+ import { describeError } from "./format.js";
12
+ import { loadInstalledPlugins } from "./InstalledPlugins.js";
13
+ import LogsPanel from "./LogsPanel.js";
14
+ import RuntimePanel from "./RuntimePanel.js";
15
+ import SystemPanel from "./SystemPanel.js";
16
+ import { useDiagnosticsResource } from "./useDiagnosticsResource.js";
17
+ import { useLogStream } from "./useLogStream.js";
18
+ import { useMetricsPolling } from "./useMetricsPolling.js";
19
+ import VersionsPanel from "./VersionsPanel.js";
20
+ const TABS = [
21
+ { id: "versions", label: "Versions" },
22
+ { id: "runtime", label: "Runtime" },
23
+ { id: "system", label: "System" },
24
+ { id: "logs", label: "Logs" },
25
+ ];
26
+ const tabId = (tab) => `diagnostics-tab-${tab}`;
27
+ const panelId = (tab) => `diagnostics-panel-${tab}`;
28
+ /**
29
+ * The Diagnostics page: what is installed (Versions), what it runs on (Runtime), how it is doing right now (System, live) and what
30
+ * the server is logging (Logs, live, with captures). Versions and Runtime are read when the page opens and again on Refresh, and
31
+ * are not polled. The live tabs only work while they are open: System polls only on its tab, and the log stream is opened the
32
+ * first time the Logs tab is (and closes with the page, or on Stop).
33
+ */
34
+ export default function DiagnosticsManager({ createLogSocket, saveFile = saveTextFile }) {
35
+ const [tab, setTab] = useState("versions");
36
+ const [reloadKey, setReloadKey] = useState(0);
37
+ const [preparingReport, setPreparingReport] = useState(false);
38
+ const versions = useDiagnosticsResource(getDiagnosticsVersions, reloadKey, "Could not read the server's versions.");
39
+ const runtime = useDiagnosticsResource(getDiagnosticsRuntime, reloadKey, "Could not read the runtime.");
40
+ const plugins = useDiagnosticsResource(loadInstalledPlugins, reloadKey, "Could not read the installed plugins.");
41
+ const polling = useMetricsPolling(tab === "system");
42
+ const logStream = useLogStream({ createSocket: createLogSocket });
43
+ const logsOpened = useRef(false);
44
+ const { start: startLogs } = logStream;
45
+ useEffect(() => {
46
+ if (tab === "logs" && !logsOpened.current) {
47
+ logsOpened.current = true;
48
+ startLogs();
49
+ }
50
+ }, [tab]);
51
+ const refreshing = versions.loading || runtime.loading || plugins.loading;
52
+ function handleTabKeyDown(event) {
53
+ const index = TABS.findIndex((item) => item.id === tab);
54
+ let next;
55
+ if (event.key === "ArrowRight") {
56
+ next = (index + 1) % TABS.length;
57
+ }
58
+ else if (event.key === "ArrowLeft") {
59
+ next = (index + TABS.length - 1) % TABS.length;
60
+ }
61
+ else if (event.key === "Home") {
62
+ next = 0;
63
+ }
64
+ else if (event.key === "End") {
65
+ next = TABS.length - 1;
66
+ }
67
+ else {
68
+ return;
69
+ }
70
+ event.preventDefault();
71
+ setTab(TABS[next].id);
72
+ document.getElementById(tabId(TABS[next].id))?.focus();
73
+ }
74
+ /** Saves the versions, the runtime and the latest metrics sample (asking for one if the System tab has not sampled): no logs. */
75
+ async function downloadReport() {
76
+ setPreparingReport(true);
77
+ const generatedAt = new Date();
78
+ let metrics = polling.history[polling.history.length - 1];
79
+ let metricsError;
80
+ if (!metrics) {
81
+ try {
82
+ metrics = await getDiagnosticsMetrics();
83
+ }
84
+ catch (err) {
85
+ metricsError = describeError(err, "Could not read the server's metrics.");
86
+ }
87
+ }
88
+ const report = {
89
+ generatedAt: generatedAt.toISOString(),
90
+ versions: versions.data ?? null,
91
+ runtime: runtime.data ?? null,
92
+ // Only what each plugin is and which version is loaded: a plugin's settings can hold secrets.
93
+ plugins: plugins.data
94
+ ? {
95
+ installed: plugins.data.plugins.map(({ name, packageVersion, enabled }) => ({ name, packageVersion, enabled })),
96
+ status: plugins.data.status ?? null,
97
+ }
98
+ : null,
99
+ metrics: metrics ?? null,
100
+ errors: {
101
+ versions: versions.error ?? null,
102
+ runtime: runtime.error ?? null,
103
+ plugins: plugins.error ?? null,
104
+ metrics: metricsError ?? null,
105
+ },
106
+ };
107
+ saveFile(timestampedFilename("rapidmx-diagnostics", "json", generatedAt), `${JSON.stringify(report, null, 2)}\n`, "application/json");
108
+ setPreparingReport(false);
109
+ }
110
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-5 flex flex-wrap items-center justify-between gap-3", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Diagnostics" }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: refreshing, onClick: () => setReloadKey(reloadKey + 1), children: [_jsx(HiOutlineArrowPath, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Refresh"] }), _jsxs(Button, { type: "button", className: "!w-auto", loading: preparingReport, onClick: () => void downloadReport(), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Download diagnostics report"] })] })] }), _jsx("div", { role: "tablist", "aria-label": "Diagnostics sections", className: "mb-5 flex gap-1 overflow-x-auto rounded-lg bg-surface-alt p-1", children: TABS.map((item) => (_jsx("button", { type: "button", role: "tab", id: tabId(item.id), "aria-selected": item.id === tab, "aria-controls": panelId(item.id), tabIndex: item.id === tab ? 0 : -1, onClick: () => setTab(item.id), onKeyDown: handleTabKeyDown, className: `flex-1 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ${item.id === tab ? "bg-surface text-text shadow-card" : "text-text-muted hover:text-text"}`, children: item.label }, item.id))) }), _jsxs("div", { role: "tabpanel", id: panelId(tab), "aria-labelledby": tabId(tab), children: [tab === "versions" && _jsx(VersionsPanel, { versions: versions, plugins: plugins }), tab === "runtime" && _jsx(RuntimePanel, { runtime: runtime }), tab === "system" && _jsx(SystemPanel, { polling: polling }), tab === "logs" && _jsx(LogsPanel, { stream: logStream, saveFile: saveFile })] })] }));
111
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
3
+ /** `1.23, 0.98, 0.5`, or a dash for a load average the server left out. */
4
+ export declare function formatLoad(load: number[] | undefined): string;
5
+ export interface HostCardProps {
6
+ host: DiagnosticsMetrics["host"] | undefined;
7
+ history: DiagnosticsMetrics[];
8
+ }
9
+ /**
10
+ * The node the server pod runs on, as its own container sees it: whole-host CPU, memory, load and disks. It is only that
11
+ * node in a cluster of several, and the whole machine on a single-node install or under Docker Compose.
12
+ */
13
+ export default function HostCard({ host, history }: HostCardProps): React.JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { formatBytes, formatCores, formatPercent, NO_VALUE, percentOf } from "./format.js";
3
+ import { seriesOf } from "./metricsHistory.js";
4
+ import MetricTile from "./MetricTile.js";
5
+ import UsageMeter from "./UsageMeter.js";
6
+ const percentLabel = (value) => formatPercent(value);
7
+ /** `1.23, 0.98, 0.5`, or a dash for a load average the server left out. */
8
+ export function formatLoad(load) {
9
+ return load && load.length > 0 ? load.map((value) => value.toFixed(2)).join(", ") : NO_VALUE;
10
+ }
11
+ function DiskRow({ disk }) {
12
+ const percent = percentOf(disk.usedBytes, disk.capacityBytes);
13
+ return (_jsxs("li", { className: "rounded-sm border border-border p-3", children: [_jsxs("div", { className: "mb-1 flex flex-wrap items-baseline gap-x-2 text-sm", children: [_jsx("span", { className: "font-mono break-all", children: disk.path }), disk.pvc && _jsxs("span", { className: "text-xs text-text-muted", children: ["volume ", disk.pvc] })] }), percent === undefined ? (_jsx("p", { className: "text-sm text-text-muted", children: "No usage figures." })) : (_jsx(UsageMeter, { label: `Disk usage of ${disk.path}`, percent: percent, detail: `${formatBytes(disk.usedBytes)} of ${formatBytes(disk.capacityBytes)}, ${formatBytes(disk.availableBytes)} free` })), disk.sharesNodeDisk && _jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Shares the node\u2019s disk." })] }));
14
+ }
15
+ /**
16
+ * The node the server pod runs on, as its own container sees it: whole-host CPU, memory, load and disks. It is only that
17
+ * node in a cluster of several, and the whole machine on a single-node install or under Docker Compose.
18
+ */
19
+ export default function HostCard({ host, history }) {
20
+ const memoryPercent = percentOf(host?.memoryUsedBytes, host?.memoryTotalBytes);
21
+ const disks = host?.disks ?? [];
22
+ return (_jsxs("section", { "aria-labelledby": "diagnostics-host-heading", className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: "diagnostics-host-heading", className: "text-base font-bold uppercase tracking-wide mb-1", children: "Node running this server" }), _jsx("p", { className: "mb-3 text-xs text-text-muted", children: "As the server\u2019s own container sees it. In a cluster of several nodes this is only the node the server runs on." }), !host ? (_jsx("p", { className: "text-sm text-text-muted", children: "The server did not report the node\u2019s figures." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-3", children: [_jsx(MetricTile, { label: "CPU", value: formatPercent(host.cpuPercent), detail: formatCores(host.cpuCount), usage: Number.isFinite(host.cpuPercent) ? { percent: host.cpuPercent, detail: "Whole host, all cores" } : undefined, history: {
23
+ values: seriesOf(history, (sample) => sample.host?.cpuPercent),
24
+ max: 100,
25
+ format: percentLabel,
26
+ } }), _jsx(MetricTile, { label: "Memory", value: formatBytes(host.memoryUsedBytes), detail: `of ${formatBytes(host.memoryTotalBytes)}`, usage: memoryPercent === undefined
27
+ ? undefined
28
+ : { percent: memoryPercent, detail: `${formatBytes(host.memoryUsedBytes)} of ${formatBytes(host.memoryTotalBytes)}` }, history: {
29
+ values: seriesOf(history, (sample) => percentOf(sample.host?.memoryUsedBytes, sample.host?.memoryTotalBytes)),
30
+ max: 100,
31
+ format: percentLabel,
32
+ } }), _jsx(MetricTile, { label: "Load average", value: formatLoad(host.loadAverage), detail: "1, 5 and 15 minutes" })] }), _jsx("h3", { className: "mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted", children: "Disks" }), disks.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No disks were reported." })) : (_jsx("ul", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: disks.map((disk) => (_jsx(DiskRow, { disk: disk }, disk.path))) }))] }))] }));
33
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { Plugin, PluginStatus } from "@rapidmx/react-shared/admin/pluginsApi.js";
3
+ /** The installed plugins, and what each server copy reports of them (absent when the status could not be read). */
4
+ export interface InstalledPluginsData {
5
+ plugins: Plugin[];
6
+ status: PluginStatus | undefined;
7
+ }
8
+ /** Reads the plugins, and their load status. A status that cannot be read leaves the list without it, not without the list. */
9
+ export declare function loadInstalledPlugins(): Promise<InstalledPluginsData>;
10
+ /** Which versions of `name` the server copies have loaded, and the load errors they report for it. */
11
+ export declare function pluginLoadState(status: PluginStatus, name: string): {
12
+ versions: string[];
13
+ loadedOn: number;
14
+ instanceCount: number;
15
+ errors: string[];
16
+ };
17
+ export interface InstalledPluginsProps {
18
+ data: InstalledPluginsData | undefined;
19
+ error: string | undefined;
20
+ loading: boolean;
21
+ }
22
+ /**
23
+ * Each installed plugin with the version configured for it and the version the running servers actually loaded. The two
24
+ * differ while a change is still being applied, or when a version could not be loaded (then the load error is shown).
25
+ */
26
+ export default function InstalledPlugins({ data, error, loading }: InstalledPluginsProps): React.JSX.Element;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { HiOutlineExclamationTriangle } from "react-icons/hi2";
3
+ import { getPluginStatus, listPlugins } from "@rapidmx/react-shared/admin/pluginsApi.js";
4
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
5
+ import Badge from "./Badge.js";
6
+ import { NO_VALUE } from "./format.js";
7
+ /** Reads the plugins, and their load status. A status that cannot be read leaves the list without it, not without the list. */
8
+ export async function loadInstalledPlugins() {
9
+ const [plugins, status] = await Promise.all([listPlugins(), getPluginStatus().catch(() => undefined)]);
10
+ return { plugins, status };
11
+ }
12
+ /** Which versions of `name` the server copies have loaded, and the load errors they report for it. */
13
+ export function pluginLoadState(status, name) {
14
+ const instances = status.instances;
15
+ const loaded = instances.flatMap((instance) => instance.loaded.filter((item) => item.name === name));
16
+ const errors = instances.flatMap((instance) => instance.errors.filter((item) => item.name === name));
17
+ return {
18
+ versions: [...new Set(loaded.map((item) => item.version))],
19
+ loadedOn: loaded.length,
20
+ instanceCount: instances.length,
21
+ errors: [...new Set(errors.map((item) => item.message))],
22
+ };
23
+ }
24
+ const HEADINGS = ["Plugin", "Configured version", "Enabled", "Loaded version", "Load errors"];
25
+ /**
26
+ * Each installed plugin with the version configured for it and the version the running servers actually loaded. The two
27
+ * differ while a change is still being applied, or when a version could not be loaded (then the load error is shown).
28
+ */
29
+ export default function InstalledPlugins({ data, error, loading }) {
30
+ let body;
31
+ if (error) {
32
+ body = _jsx(Alert, { children: error });
33
+ }
34
+ else if (!data) {
35
+ body = loading ? _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }) : null;
36
+ }
37
+ else if (data.plugins.length === 0) {
38
+ body = _jsx("p", { className: "text-sm text-text-muted", children: "No plugins are installed." });
39
+ }
40
+ else {
41
+ body = (_jsxs(_Fragment, { children: [!data.status && (_jsx("p", { role: "status", className: "mb-2 text-xs text-text-muted", children: "What the servers have loaded could not be read, so only the configured versions are shown." })), _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: data.plugins.map((plugin) => (_jsx(PluginRow, { plugin: plugin, status: data.status }, plugin.uid))) })] }) })] }));
42
+ }
43
+ return (_jsxs("section", { "aria-labelledby": "diagnostics-plugins-heading", className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: "diagnostics-plugins-heading", className: "text-base font-bold uppercase tracking-wide mb-3", children: "Installed plugins" }), body] }));
44
+ }
45
+ function PluginRow({ plugin, status }) {
46
+ const state = status ? pluginLoadState(status, plugin.name) : undefined;
47
+ let loaded = NO_VALUE;
48
+ if (state) {
49
+ if (state.versions.length > 0) {
50
+ loaded = `${state.versions.join(", ")} (on ${state.loadedOn} of ${state.instanceCount} servers)`;
51
+ }
52
+ else if (plugin.enabled) {
53
+ loaded = "Not loaded";
54
+ }
55
+ }
56
+ const differs = state !== undefined && state.versions.some((version) => version !== plugin.packageVersion);
57
+ return (_jsxs("tr", { children: [_jsxs("td", { className: "py-2 px-2.5 border-b border-border", children: [_jsx("div", { children: plugin.manifest.displayName ?? plugin.name }), _jsx("div", { className: "font-mono text-xs text-text-muted break-all", children: plugin.name })] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono", children: plugin.packageVersion }), _jsx("td", { className: "py-2 px-2.5 border-b border-border", children: _jsx(Badge, { tone: plugin.enabled ? "success" : "neutral", children: plugin.enabled ? "Enabled" : "Disabled" }) }), _jsxs("td", { className: "py-2 px-2.5 border-b border-border", children: [_jsx("span", { className: "font-mono", children: loaded }), differs && _jsx("div", { className: "text-xs text-text-muted", children: "Differs from the configured version" })] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border", children: state && state.errors.length > 0
58
+ ? state.errors.map((message) => (_jsxs("div", { className: "flex items-start gap-1", children: [_jsx(HiOutlineExclamationTriangle, { size: 16, "aria-hidden": "true", className: "mt-0.5 shrink-0 text-danger" }), _jsx("span", { className: "text-text break-words", children: message })] }, message)))
59
+ : NO_VALUE })] }));
60
+ }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ /**
3
+ * Said where Kubernetes information would be, when the server cannot reach a cluster. That is ordinary (for one, when
4
+ * RapidMX runs under Docker Compose), so it is a plain note and not an error.
5
+ */
6
+ export default function KubernetesNotice({ reason }: {
7
+ reason?: string;
8
+ }): React.JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { HiOutlineInformationCircle } from "react-icons/hi2";
3
+ /**
4
+ * Said where Kubernetes information would be, when the server cannot reach a cluster. That is ordinary (for one, when
5
+ * RapidMX runs under Docker Compose), so it is a plain note and not an error.
6
+ */
7
+ export default function KubernetesNotice({ reason }) {
8
+ return (_jsxs("div", { role: "status", className: "flex items-start gap-3 rounded-md border border-border bg-surface-alt p-4 text-sm", children: [_jsx(HiOutlineInformationCircle, { size: 20, "aria-hidden": "true", className: "mt-0.5 shrink-0 text-text-muted" }), _jsxs("div", { children: [_jsx("p", { className: "font-medium", children: "Kubernetes information is not available on this server." }), reason && _jsx("p", { className: "mt-1 text-text-muted", children: reason }), _jsx("p", { className: "mt-1 text-text-muted", children: "This is expected when RapidMX does not run in a Kubernetes cluster, for example under Docker Compose." })] })] }));
9
+ }
@@ -0,0 +1,37 @@
1
+ import type { LogEntry } from "./logLines.js";
2
+ /** How many lines the log view keeps. Older lines are dropped, and counted. */
3
+ export declare const LOG_BUFFER_LIMIT = 5000;
4
+ /** How many lines one capture records. */
5
+ export declare const LOG_CAPTURE_LIMIT = 50000;
6
+ /** What a capture has recorded so far. */
7
+ export interface LogCapture {
8
+ /** Whether lines are still being recorded. */
9
+ active: boolean;
10
+ /** When it started and, once stopped, ended (`Date.now()`). */
11
+ startedAt: number;
12
+ stoppedAt: number | undefined;
13
+ entries: LogEntry[];
14
+ /** How many lines arrived after `LOG_CAPTURE_LIMIT` was reached, and so were not recorded. */
15
+ missed: number;
16
+ }
17
+ /**
18
+ * 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
19
+ * last `LOG_BUFFER_LIMIT` lines. A capture is separate: it records every line that arrives while it runs, whatever the display
20
+ * filters show and however many lines the display buffer has since dropped, up to `LOG_CAPTURE_LIMIT`.
21
+ */
22
+ export declare class LogStore {
23
+ private readonly bufferLimit;
24
+ private readonly captureLimit;
25
+ entries: LogEntry[];
26
+ /** How many lines have been dropped from the front of `entries`. */
27
+ dropped: number;
28
+ capture: LogCapture | undefined;
29
+ constructor(bufferLimit?: number, captureLimit?: number);
30
+ add(entry: LogEntry): void;
31
+ /** Empties the display buffer. A capture in progress is not touched. */
32
+ clear(): void;
33
+ /** Starts recording (from nothing: an earlier capture is replaced). */
34
+ startCapture(now: number): void;
35
+ /** Stops recording. What was recorded is kept for download. */
36
+ stopCapture(now: number): void;
37
+ }
@@ -0,0 +1,50 @@
1
+ /** How many lines the log view keeps. Older lines are dropped, and counted. */
2
+ export const LOG_BUFFER_LIMIT = 5000;
3
+ /** How many lines one capture records. */
4
+ export const LOG_CAPTURE_LIMIT = 50000;
5
+ /**
6
+ * 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
7
+ * last `LOG_BUFFER_LIMIT` lines. A capture is separate: it records every line that arrives while it runs, whatever the display
8
+ * filters show and however many lines the display buffer has since dropped, up to `LOG_CAPTURE_LIMIT`.
9
+ */
10
+ export class LogStore {
11
+ constructor(bufferLimit = LOG_BUFFER_LIMIT, captureLimit = LOG_CAPTURE_LIMIT) {
12
+ this.bufferLimit = bufferLimit;
13
+ this.captureLimit = captureLimit;
14
+ this.entries = [];
15
+ /** How many lines have been dropped from the front of `entries`. */
16
+ this.dropped = 0;
17
+ }
18
+ add(entry) {
19
+ this.entries.push(entry);
20
+ if (this.entries.length > this.bufferLimit) {
21
+ const excess = this.entries.length - this.bufferLimit;
22
+ this.entries.splice(0, excess);
23
+ this.dropped += excess;
24
+ }
25
+ const capture = this.capture;
26
+ if (capture?.active) {
27
+ if (capture.entries.length < this.captureLimit) {
28
+ capture.entries.push(entry);
29
+ }
30
+ else {
31
+ capture.missed += 1;
32
+ }
33
+ }
34
+ }
35
+ /** Empties the display buffer. A capture in progress is not touched. */
36
+ clear() {
37
+ this.entries = [];
38
+ this.dropped = 0;
39
+ }
40
+ /** Starts recording (from nothing: an earlier capture is replaced). */
41
+ startCapture(now) {
42
+ this.capture = { active: true, startedAt: now, stoppedAt: undefined, entries: [], missed: 0 };
43
+ }
44
+ /** Stops recording. What was recorded is kept for download. */
45
+ stopCapture(now) {
46
+ if (this.capture?.active) {
47
+ this.capture = { ...this.capture, active: false, stoppedAt: now };
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { LogEntry } from "./logLines.js";
3
+ /** The most rows the viewer puts in the page: the last this many lines that match. (The buffer behind it is bigger.) */
4
+ export declare const LOG_VISIBLE_ROWS = 500;
5
+ /** What is written in a line's level column: the level, or a marker for a line without one. */
6
+ export declare function levelLabel(entry: LogEntry): string;
7
+ /** A line's `stack` (which is shown as lines of its own), and its other extra fields. */
8
+ export declare function splitExtra(extra: Record<string, unknown>): {
9
+ stack: string | undefined;
10
+ rest: Record<string, unknown>;
11
+ };
12
+ export declare function LevelBadge({ entry }: {
13
+ entry: LogEntry;
14
+ }): React.JSX.Element;
15
+ export interface LogViewerProps {
16
+ /** The lines to show, oldest first; only the last `LOG_VISIBLE_ROWS` are put in the page. */
17
+ entries: LogEntry[];
18
+ /** Whether the view stays scrolled to the newest line. */
19
+ follow: boolean;
20
+ /** Said when there is nothing to show. */
21
+ empty: string;
22
+ }
23
+ /** The lines, as monospace text in a scrolling box. The box is a live log region, but not announced line by line. */
24
+ export default function LogViewer({ entries, follow, empty }: LogViewerProps): React.JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef } from "react";
7
+ import { entryTime } from "./logLines.js";
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
+ /** The tint behind a level's name. The name is always written, so the tint only helps the eye find it. */
11
+ const LEVEL_CLASSES = {
12
+ error: "bg-danger/20",
13
+ warn: "bg-warning/30",
14
+ info: "bg-primary/15",
15
+ http: "bg-accent/20",
16
+ };
17
+ /** What is written in a line's level column: the level, or a marker for a line without one. */
18
+ export function levelLabel(entry) {
19
+ if (entry.level) {
20
+ return entry.level.toUpperCase();
21
+ }
22
+ return entry.structured ? "—" : "RAW";
23
+ }
24
+ /** A line's `stack` (which is shown as lines of its own), and its other extra fields. */
25
+ export function splitExtra(extra) {
26
+ const { stack, ...rest } = extra;
27
+ return typeof stack === "string" ? { stack, rest } : { stack: undefined, rest: extra };
28
+ }
29
+ export function LevelBadge({ entry }) {
30
+ return (_jsx("span", { className: `inline-block w-16 shrink-0 rounded-sm px-1 text-center font-semibold ${LEVEL_CLASSES[entry.level] ?? "bg-surface"}`, children: levelLabel(entry) }));
31
+ }
32
+ function LogRow({ entry }) {
33
+ const { stack, rest } = splitExtra(entry.extra);
34
+ return (_jsxs("div", { className: "py-0.5 break-words", children: [_jsx("span", { className: "text-text-muted", children: entryTime(entry) }), " ", _jsx(LevelBadge, { entry: entry }), " ", _jsx("span", { className: "whitespace-pre-wrap", children: entry.message }), Object.keys(rest).length > 0 && _jsxs("span", { className: "text-text-muted", children: [" ", JSON.stringify(rest)] }), stack !== undefined && _jsx("div", { className: "whitespace-pre-wrap pl-4 text-text-muted", children: stack })] }));
35
+ }
36
+ /** The lines, as monospace text in a scrolling box. The box is a live log region, but not announced line by line. */
37
+ export default function LogViewer({ entries, follow, empty }) {
38
+ const box = useRef(null);
39
+ const shown = entries.slice(-LOG_VISIBLE_ROWS);
40
+ useEffect(() => {
41
+ if (follow) {
42
+ // Mounted by the time an effect runs.
43
+ box.current.scrollTop = box.current.scrollHeight;
44
+ }
45
+ }, [shown.length, entries[entries.length - 1]?.id, follow]);
46
+ return (_jsx("div", { ref: box, role: "log", "aria-label": "Server log", "aria-live": "off", tabIndex: 0, className: "h-[28rem] overflow-auto rounded-md border border-border bg-surface-alt p-2 font-mono text-xs", children: shown.length === 0 ? _jsx("p", { className: "text-text-muted", children: empty }) : shown.map((entry) => _jsx(LogRow, { entry: entry }, entry.id)) }));
47
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { SaveFile } from "./download.js";
3
+ import type { LogStream } from "./useLogStream.js";
4
+ export interface LogsPanelProps {
5
+ stream: LogStream;
6
+ /** Saves a download. */
7
+ saveFile: SaveFile;
8
+ }
9
+ /**
10
+ * The Logs tab: the server's log stream as it happens, with filters, and captures. A capture records every line from the
11
+ * moment it starts, whatever the filters show, and can be downloaded (as can the display buffer) as a `.log` or `.ndjson` file.
12
+ */
13
+ export default function LogsPanel({ stream, saveFile }: LogsPanelProps): React.JSX.Element;
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { HiOutlineArrowDownTray, HiOutlineArrowPath, HiOutlineCheck, HiOutlineExclamationTriangle, HiOutlinePause, HiOutlinePlay, HiOutlineSignal, HiOutlineSignalSlash, HiOutlineStop, HiOutlineTrash, } from "react-icons/hi2";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+ import Badge from "./Badge.js";
11
+ import { timestampedFilename } from "./download.js";
12
+ import { formatUptime } from "./format.js";
13
+ import { entriesToLog, entriesToNdjson, entryMatches, LOG_LEVELS, levelVisible } from "./logLines.js";
14
+ import { LOG_BUFFER_LIMIT, LOG_CAPTURE_LIMIT } from "./LogStore.js";
15
+ import LogViewer, { LOG_VISIBLE_ROWS } from "./LogViewer.js";
16
+ const STATUS = {
17
+ connecting: { label: "Connecting", tone: "warning", icon: HiOutlineArrowPath },
18
+ live: { label: "Live", tone: "success", icon: HiOutlineSignal },
19
+ reconnecting: { label: "Reconnecting", tone: "warning", icon: HiOutlineArrowPath },
20
+ closed: { label: "Stopped", tone: "neutral", icon: HiOutlineSignalSlash },
21
+ error: { label: "Error", tone: "danger", icon: HiOutlineExclamationTriangle },
22
+ };
23
+ const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
24
+ const MIME_TYPES = { log: "text/plain;charset=utf-8", ndjson: "application/x-ndjson" };
25
+ /**
26
+ * The Logs tab: the server's log stream as it happens, with filters, and captures. A capture records every line from the
27
+ * moment it starts, whatever the filters show, and can be downloaded (as can the display buffer) as a `.log` or `.ndjson` file.
28
+ */
29
+ export default function LogsPanel({ stream, saveFile }) {
30
+ const { status, error, running, entries, dropped, capture } = stream;
31
+ const [levels, setLevels] = useState(() => new Set(LOG_LEVELS));
32
+ const [search, setSearch] = useState("");
33
+ const [follow, setFollow] = useState(true);
34
+ const [now, setNow] = useState(() => Date.now());
35
+ // The clock the recording time counts against, ticking only while a capture records.
36
+ const recording = capture?.active === true;
37
+ useEffect(() => {
38
+ if (!recording) {
39
+ return;
40
+ }
41
+ setNow(Date.now());
42
+ const timer = setInterval(() => setNow(Date.now()), 1000);
43
+ return () => clearInterval(timer);
44
+ }, [recording]);
45
+ const needle = search.trim().toLowerCase();
46
+ const matching = useMemo(() => entries.filter((entry) => levelVisible(entry, levels) && entryMatches(entry, needle)), [entries, levels, needle]);
47
+ const filtered = matching.length !== entries.length;
48
+ const badge = STATUS[status];
49
+ function toggleLevel(level) {
50
+ const next = new Set(levels);
51
+ if (!next.delete(level)) {
52
+ next.add(level);
53
+ }
54
+ setLevels(next);
55
+ }
56
+ function download(lines, format) {
57
+ saveFile(timestampedFilename("rapidmx-server-logs", format), format === "log" ? entriesToLog(lines) : entriesToNdjson(lines), MIME_TYPES[format]);
58
+ }
59
+ return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(Badge, { tone: badge.tone, icon: badge.icon, children: badge.label }), running ? (_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: stream.stop, children: [_jsx(HiOutlineStop, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Stop"] })) : (_jsxs(Button, { type: "button", className: "!w-auto", onClick: stream.start, children: [_jsx(HiOutlinePlay, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Start"] })), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-pressed": !follow, onClick: () => setFollow(!follow), children: [_jsx(HiOutlinePause, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), follow ? "Pause scrolling" : "Resume scrolling"] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: stream.clear, children: [_jsx(HiOutlineTrash, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Clear"] })] }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("div", { role: "group", "aria-label": "Levels", className: "flex flex-wrap items-center gap-1", children: LOG_LEVELS.map((level) => (_jsxs("button", { type: "button", "aria-pressed": levels.has(level), onClick: () => toggleLevel(level), className: `inline-flex items-center gap-1 rounded-pill border px-2.5 py-1 text-xs font-medium ${levels.has(level) ? "border-primary bg-primary/10 text-text" : "border-border bg-surface text-text-muted"}`, children: [levels.has(level) && _jsx(HiOutlineCheck, { size: 12, "aria-hidden": "true" }), level] }, level))) }), _jsx("div", { className: "min-w-48 flex-1", children: _jsx("input", { type: "search", "aria-label": "Search the log", className: INPUT_CLASS, placeholder: "Search the log", value: search, onChange: (event) => setSearch(event.target.value) }) })] }), _jsxs("div", { role: "status", className: "text-xs text-text-muted", children: [filtered
60
+ ? `${matching.length} of ${entries.length} buffered lines match.`
61
+ : `${entries.length} buffered ${entries.length === 1 ? "line" : "lines"}.`, matching.length > LOG_VISIBLE_ROWS && ` Showing the last ${LOG_VISIBLE_ROWS}.`, dropped > 0 && ` ${dropped} older ${dropped === 1 ? "line" : "lines"} dropped (the buffer keeps the last ${LOG_BUFFER_LIMIT}).`] }), _jsx(LogViewer, { entries: matching, follow: follow, empty: entries.length === 0 ? "No log lines yet." : "No buffered line matches the filters." }), _jsxs("section", { "aria-labelledby": "diagnostics-capture-heading", className: "rounded-md border border-border bg-surface p-4", children: [_jsx("h2", { id: "diagnostics-capture-heading", className: "text-base font-bold uppercase tracking-wide mb-1", children: "Capture" }), _jsxs("p", { className: "mb-3 text-xs text-text-muted", children: ["A capture records every line received while it runs, whatever the filters show, up to ", LOG_CAPTURE_LIMIT.toLocaleString(), " lines."] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [recording ? (_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: stream.stopCapture, children: [_jsx(HiOutlineStop, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Stop capture"] })) : (_jsxs(Button, { type: "button", className: "!w-auto", onClick: stream.startCapture, children: [_jsx(HiOutlinePlay, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), "Start capture"] })), capture && (_jsxs("p", { role: "status", className: "text-sm", children: [recording ? "Recording" : "Capture stopped", ": ", capture.count.toLocaleString(), " ", capture.count === 1 ? "line" : "lines", " in", " ", formatUptime(((capture.stoppedAt ?? now) - capture.startedAt) / 1000)] }))] }), capture && capture.missed > 0 && (_jsxs("p", { className: "mt-2 text-sm", children: ["The capture is full: ", capture.missed.toLocaleString(), " later ", capture.missed === 1 ? "line was" : "lines were", " not recorded."] })), _jsxs("div", { className: "mt-3 flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-xs uppercase tracking-wide text-text-muted", children: "Download" }), ["log", "ndjson"].map((format) => (_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: entries.length === 0, onClick: () => download(entries, format), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), `Buffer (.${format})`] }, `buffer-${format}`))), capture &&
62
+ ["log", "ndjson"].map((format) => (_jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: capture.count === 0, onClick: () => download(stream.captureEntries(), format), children: [_jsx(HiOutlineArrowDownTray, { size: 16, "aria-hidden": "true", className: "mr-1 inline" }), `Capture (.${format})`] }, `capture-${format}`)))] }), _jsx("p", { className: "mt-2 text-xs text-text-muted", children: "The buffer download holds every buffered line, not only the ones the filters show." })] })] }));
63
+ }
@@ -0,0 +1,21 @@
1
+ import React, { ReactNode } from "react";
2
+ import { SparklineProps } from "./Sparkline.js";
3
+ export interface MetricTileProps {
4
+ /** What is measured (`CPU`). */
5
+ label: string;
6
+ /** The current value, written out (`1.2 cores`). */
7
+ value: string;
8
+ /** Smaller text under the value. */
9
+ detail?: string;
10
+ /** When the value is a share of a capacity: the share (0 to 100) and the amounts it comes from. */
11
+ usage?: {
12
+ percent: number;
13
+ detail: string;
14
+ flag?: boolean;
15
+ };
16
+ /** The value's recent history, drawn under it. */
17
+ history?: Omit<SparklineProps, "label">;
18
+ children?: ReactNode;
19
+ }
20
+ /** One measurement: its label, the current value in numbers, a meter when it has a capacity, and a sparkline of its recent past. */
21
+ export default function MetricTile({ label, value, detail, usage, history, children }: MetricTileProps): React.JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import Sparkline from "./Sparkline.js";
3
+ import UsageMeter from "./UsageMeter.js";
4
+ /** One measurement: its label, the current value in numbers, a meter when it has a capacity, and a sparkline of its recent past. */
5
+ export default function MetricTile({ label, value, detail, usage, history, children }) {
6
+ return (_jsxs("div", { className: "rounded-md border border-border bg-surface p-4", children: [_jsx("div", { className: "text-xs uppercase tracking-wide text-text-muted", children: label }), _jsx("div", { className: "mt-1 text-xl font-semibold", children: value }), detail && _jsx("div", { className: "text-xs text-text-muted", children: detail }), usage && (_jsx("div", { className: "mt-3", children: _jsx(UsageMeter, { label: `${label} usage`, percent: usage.percent, detail: usage.detail, flag: usage.flag }) })), history && (_jsx("div", { className: "mt-3", children: _jsx(Sparkline, { label: label, ...history }) })), children] }));
7
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
3
+ type NamespaceMetrics = NonNullable<DiagnosticsMetrics["kubernetes"]["namespace"]>;
4
+ export interface NamespaceCardProps {
5
+ namespace: NamespaceMetrics | undefined;
6
+ history: DiagnosticsMetrics[];
7
+ }
8
+ /**
9
+ * The install's namespace: what all its pods use together, and what each pod uses. The figures come from metrics-server; without
10
+ * it there are none, and that is said as a hint (it is a choice of the cluster), not as a failure.
11
+ */
12
+ export default function NamespaceCard({ namespace, history }: NamespaceCardProps): React.JSX.Element;
13
+ export {};
@@ -0,0 +1,15 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { HiOutlineInformationCircle } from "react-icons/hi2";
3
+ import { formatBytes, formatCores, NO_VALUE } from "./format.js";
4
+ import { seriesOf } from "./metricsHistory.js";
5
+ import MetricTile from "./MetricTile.js";
6
+ const POD_HEADINGS = ["Pod", "Component", "CPU", "Memory"];
7
+ /**
8
+ * The install's namespace: what all its pods use together, and what each pod uses. The figures come from metrics-server; without
9
+ * it there are none, and that is said as a hint (it is a choice of the cluster), not as a failure.
10
+ */
11
+ export default function NamespaceCard({ namespace, history }) {
12
+ // Biggest memory first; a pod with no figure last.
13
+ const pods = [...(namespace?.pods ?? [])].sort((a, b) => (b.memoryUsedBytes ?? -1) - (a.memoryUsedBytes ?? -1));
14
+ return (_jsxs("section", { "aria-labelledby": "diagnostics-namespace-heading", className: "rounded-md border border-border bg-surface p-4", children: [_jsxs("h2", { id: "diagnostics-namespace-heading", className: "text-base font-bold uppercase tracking-wide mb-3", children: ["Namespace", namespace?.name ? ` ${namespace.name}` : ""] }), !namespace ? (_jsx("p", { className: "text-sm text-text-muted", children: "The server did not report the namespace\u2019s figures." })) : !namespace.podMetricsAvailable ? (_jsxs("div", { role: "status", className: "flex items-start gap-3 rounded-md border border-border bg-surface-alt p-4 text-sm", children: [_jsx(HiOutlineInformationCircle, { size: 20, "aria-hidden": "true", className: "mt-0.5 shrink-0 text-text-muted" }), _jsxs("div", { children: [_jsx("p", { className: "font-medium", children: "Per-pod CPU and memory are not available." }), _jsxs("p", { className: "mt-1 text-text-muted", children: ["They come from metrics-server, which this cluster does not provide or the server may not read.", namespace.podMetricsReason ? ` ${namespace.podMetricsReason}` : ""] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [_jsx(MetricTile, { label: "Namespace CPU", value: formatCores(namespace.cpuUsedCores), history: { values: seriesOf(history, (sample) => sample.kubernetes?.namespace?.cpuUsedCores), format: formatCores } }), _jsx(MetricTile, { label: "Namespace memory", value: formatBytes(namespace.memoryUsedBytes), history: { values: seriesOf(history, (sample) => sample.kubernetes?.namespace?.memoryUsedBytes), format: formatBytes } })] }), _jsx("h3", { className: "mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted", children: "Pods" }), pods.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No pod figures were reported." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: POD_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: pods.map((pod) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: pod.name }), _jsx("td", { className: "py-2 px-2.5 border-b border-border", children: pod.component ?? NO_VALUE }), _jsx("td", { className: "py-2 px-2.5 border-b border-border tabular-nums", children: formatCores(pod.cpuUsedCores) }), _jsx("td", { className: "py-2 px-2.5 border-b border-border tabular-nums", children: formatBytes(pod.memoryUsedBytes) })] }, pod.name))) })] }) }))] }))] }));
15
+ }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import type { DiagnosticsVersions } from "./diagnosticsApi.js";
3
+ /** How many packages are listed at first, and how many more each "Show more" adds: the list can be a thousand long. */
4
+ export declare const PACKAGES_PAGE_SIZE = 100;
5
+ /** Every package installed on the server, filtered by a name and paged, with a badge on the ones the server itself depends on. */
6
+ export default function PackagesTable({ packages }: {
7
+ packages: DiagnosticsVersions["packages"];
8
+ }): React.JSX.Element;