@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,70 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
7
+ import { formatBytes, formatCores, formatPercent, percentOf } from "./format.js";
8
+ import { formatLoad } from "./HostCard.js";
9
+ import { seriesOf } from "./metricsHistory.js";
10
+ import MetricTile from "./MetricTile.js";
11
+
12
+ export interface ProcessCardProps {
13
+ process: DiagnosticsMetrics["process"] | undefined;
14
+ history: DiagnosticsMetrics[];
15
+ }
16
+
17
+ /** The server's own Node.js process: its CPU, memory, and the memory and load its container sees. Shown with or without Kubernetes. */
18
+ export default function ProcessCard({ process, history }: ProcessCardProps) {
19
+ const heapPercent = percentOf(process?.heapUsedBytes, process?.heapTotalBytes);
20
+ const systemUsed =
21
+ process?.systemMemoryTotalBytes !== undefined && process.systemMemoryFreeBytes !== undefined
22
+ ? process.systemMemoryTotalBytes - process.systemMemoryFreeBytes
23
+ : undefined;
24
+ const systemPercent = percentOf(systemUsed, process?.systemMemoryTotalBytes);
25
+ return (
26
+ <section aria-labelledby="diagnostics-process-heading" className="rounded-md border border-border bg-surface p-4">
27
+ <h2 id="diagnostics-process-heading" className="text-base font-bold uppercase tracking-wide mb-3">
28
+ This server process
29
+ </h2>
30
+ {!process ? (
31
+ <p className="text-sm text-text-muted">The server did not report its own figures.</p>
32
+ ) : (
33
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
34
+ <MetricTile
35
+ label="Process CPU"
36
+ value={formatPercent(process.cpuPercent)}
37
+ detail={`${formatCores(process.cpuCount)} available`}
38
+ history={{ values: seriesOf(history, (sample) => sample.process?.cpuPercent), format: formatPercent }}
39
+ />
40
+ <MetricTile
41
+ label="Process memory (RSS)"
42
+ value={formatBytes(process.rssBytes)}
43
+ history={{ values: seriesOf(history, (sample) => sample.process?.rssBytes), format: formatBytes }}
44
+ />
45
+ <MetricTile
46
+ label="Heap"
47
+ value={formatBytes(process.heapUsedBytes)}
48
+ detail={`of ${formatBytes(process.heapTotalBytes)} allocated`}
49
+ usage={
50
+ heapPercent === undefined
51
+ ? undefined
52
+ : { percent: heapPercent, detail: `${formatBytes(process.heapUsedBytes)} of ${formatBytes(process.heapTotalBytes)}`, flag: false }
53
+ }
54
+ />
55
+ <MetricTile label="Load average" value={formatLoad(process.loadAverage)} detail="1, 5 and 15 minutes" />
56
+ <MetricTile
57
+ label="Memory visible to the container"
58
+ value={formatBytes(systemUsed)}
59
+ detail={`of ${formatBytes(process.systemMemoryTotalBytes)}`}
60
+ usage={
61
+ systemPercent === undefined
62
+ ? undefined
63
+ : { percent: systemPercent, detail: `${formatBytes(systemUsed)} of ${formatBytes(process.systemMemoryTotalBytes)}` }
64
+ }
65
+ />
66
+ </div>
67
+ )}
68
+ </section>
69
+ );
70
+ }
@@ -0,0 +1,79 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { DiagnosticsPvcMetrics } from "./diagnosticsApi.js";
7
+ import { formatBytes, NO_VALUE, percentOf } from "./format.js";
8
+ import UsageMeter from "./UsageMeter.js";
9
+
10
+ const HEADINGS = ["Volume", "Phase", "Storage class", "Size", "Usage"];
11
+
12
+ function Usage({ pvc }: { pvc: DiagnosticsPvcMetrics }) {
13
+ if (!pvc.mountedByServer) {
14
+ return <span className="text-text-muted">Usage not available: not mounted in the server pod.</span>;
15
+ }
16
+ const percent = percentOf(pvc.usedBytes, pvc.capacityBytes);
17
+ if (percent === undefined) {
18
+ return <span className="text-text-muted">The server has no usage figures for this volume.</span>;
19
+ }
20
+ return (
21
+ <div>
22
+ <UsageMeter
23
+ label={`Usage of volume ${pvc.name}`}
24
+ percent={percent}
25
+ detail={`${formatBytes(pvc.usedBytes)} of ${formatBytes(pvc.capacityBytes)}`}
26
+ flag={!pvc.sharesNodeDisk}
27
+ />
28
+ {pvc.sharesNodeDisk && (
29
+ <p className="mt-1 text-xs text-text-muted">Shares the node&rsquo;s disk; the volume&rsquo;s size is not enforced.</p>
30
+ )}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ /** The install's persistent volume claims, with a usage bar for the ones the server can measure. */
36
+ export default function PvcTable({ pvcs }: { pvcs: DiagnosticsPvcMetrics[] }) {
37
+ return (
38
+ <section aria-labelledby="diagnostics-pvc-heading" className="rounded-md border border-border bg-surface p-4">
39
+ <h2 id="diagnostics-pvc-heading" className="text-base font-bold uppercase tracking-wide mb-3">
40
+ Persistent volumes
41
+ </h2>
42
+ {pvcs.length === 0 ? (
43
+ <p className="text-sm text-text-muted">This install has no persistent volume claims.</p>
44
+ ) : (
45
+ <div className="overflow-x-auto">
46
+ <table className="w-full text-sm border-collapse">
47
+ <thead>
48
+ <tr>
49
+ {HEADINGS.map((heading) => (
50
+ <th
51
+ key={heading}
52
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
53
+ >
54
+ {heading}
55
+ </th>
56
+ ))}
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {pvcs.map((pvc) => (
61
+ <tr key={pvc.name} className="align-top">
62
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">{pvc.name}</td>
63
+ <td className="py-2 px-2.5 border-b border-border">{pvc.phase}</td>
64
+ <td className="py-2 px-2.5 border-b border-border">{pvc.storageClass ?? NO_VALUE}</td>
65
+ <td className="py-2 px-2.5 border-b border-border tabular-nums">
66
+ {formatBytes(pvc.capacityBytes ?? pvc.requestedBytes)}
67
+ </td>
68
+ <td className="py-2 px-2.5 border-b border-border min-w-56">
69
+ <Usage pvc={pvc} />
70
+ </td>
71
+ </tr>
72
+ ))}
73
+ </tbody>
74
+ </table>
75
+ </div>
76
+ )}
77
+ </section>
78
+ );
79
+ }
@@ -0,0 +1,110 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
7
+ import type { DiagnosticsRuntime } from "./diagnosticsApi.js";
8
+ import type { DiagnosticsResource } from "./useDiagnosticsResource.js";
9
+ import Badge from "./Badge.js";
10
+ import KubernetesNotice from "./KubernetesNotice.js";
11
+ import { formatDateTime, NO_VALUE } from "./format.js";
12
+
13
+ /** What a distribution's id is called on screen. */
14
+ const DISTRIBUTIONS: Record<string, string> = {
15
+ k3s: "k3s",
16
+ rke2: "RKE2",
17
+ eks: "Amazon EKS",
18
+ gke: "Google GKE",
19
+ aks: "Azure AKS",
20
+ kubernetes: "Kubernetes",
21
+ };
22
+
23
+ const NODE_HEADINGS = ["Node", "Internal IP", "Pods of this install"];
24
+
25
+ function Fact({ term, children }: { term: string; children: React.ReactNode }) {
26
+ return (
27
+ <div>
28
+ <dt className="text-xs uppercase tracking-wide text-text-muted">{term}</dt>
29
+ <dd className="break-words">{children}</dd>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ function RuntimeDetails({ runtime }: { runtime: DiagnosticsRuntime }) {
35
+ const version = runtime.version;
36
+ const nodes = runtime.nodes ?? [];
37
+ return (
38
+ <>
39
+ <section aria-labelledby="diagnostics-kubernetes-heading" className="rounded-md border border-border bg-surface p-4">
40
+ <h2 id="diagnostics-kubernetes-heading" className="text-base font-bold uppercase tracking-wide mb-3">
41
+ Kubernetes
42
+ </h2>
43
+ <dl className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
44
+ <Fact term="Version">
45
+ <span className="font-mono">{version?.gitVersion ?? NO_VALUE}</span>
46
+ </Fact>
47
+ <Fact term="Distribution">
48
+ {version ? <Badge tone="info">{DISTRIBUTIONS[version.distribution] ?? version.distribution}</Badge> : NO_VALUE}
49
+ </Fact>
50
+ <Fact term="Platform">{version?.platform ?? NO_VALUE}</Fact>
51
+ <Fact term="Namespace">
52
+ <span className="font-mono">{runtime.namespace ?? NO_VALUE}</span>
53
+ </Fact>
54
+ <Fact term="Go version">{version?.goVersion ?? NO_VALUE}</Fact>
55
+ <Fact term="Built">{formatDateTime(version?.buildDate)}</Fact>
56
+ </dl>
57
+ </section>
58
+ <section aria-labelledby="diagnostics-nodes-heading" className="rounded-md border border-border bg-surface p-4">
59
+ <h2 id="diagnostics-nodes-heading" className="text-base font-bold uppercase tracking-wide mb-1">
60
+ Nodes
61
+ </h2>
62
+ <p className="mb-3 text-xs text-text-muted">
63
+ The nodes that run this install&rsquo;s pods. The server can only see its own namespace, so nothing about the nodes
64
+ themselves is listed here.
65
+ </p>
66
+ {nodes.length === 0 ? (
67
+ <p className="text-sm text-text-muted">No node is running any of this install&rsquo;s pods.</p>
68
+ ) : (
69
+ <div className="overflow-x-auto">
70
+ <table className="w-full text-sm border-collapse">
71
+ <thead>
72
+ <tr>
73
+ {NODE_HEADINGS.map((heading) => (
74
+ <th
75
+ key={heading}
76
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
77
+ >
78
+ {heading}
79
+ </th>
80
+ ))}
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ {nodes.map((node) => (
85
+ <tr key={node.name}>
86
+ <td className="py-2 px-2.5 border-b border-border font-mono break-all">{node.name}</td>
87
+ <td className="py-2 px-2.5 border-b border-border font-mono">{node.internalIP ?? NO_VALUE}</td>
88
+ <td className="py-2 px-2.5 border-b border-border tabular-nums">{node.podCount}</td>
89
+ </tr>
90
+ ))}
91
+ </tbody>
92
+ </table>
93
+ </div>
94
+ )}
95
+ </section>
96
+ </>
97
+ );
98
+ }
99
+
100
+ /** The Runtime tab: the Kubernetes version and the nodes the install's pods run on. */
101
+ export default function RuntimePanel({ runtime }: { runtime: DiagnosticsResource<DiagnosticsRuntime> }) {
102
+ const { data, error, loading } = runtime;
103
+ return (
104
+ <div className="space-y-6">
105
+ {error && <Alert>{error}</Alert>}
106
+ {!data && loading && <p className="text-sm text-text-muted">Loading&hellip;</p>}
107
+ {data && (data.available ? <RuntimeDetails runtime={data} /> : <KubernetesNotice reason={data.reason} />)}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,37 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { DiagnosticsVersions } from "./diagnosticsApi.js";
7
+ import { formatDateTime, formatUptime, NO_VALUE } from "./format.js";
8
+
9
+ /** The server process: Node.js, the deployed package, the machine and how long it has been up. */
10
+ export default function ServerVersionCard({ server }: { server: DiagnosticsVersions["server"] }) {
11
+ const rows: [string, string][] = [
12
+ ["Node.js", server.nodeVersion || NO_VALUE],
13
+ ["V8", server.v8Version || NO_VALUE],
14
+ ["Package", server.packageName ? `${server.packageName} ${server.packageVersion ?? ""}`.trim() : NO_VALUE],
15
+ ["Environment", server.nodeEnv || NO_VALUE],
16
+ ["Platform", [server.platform, server.arch].filter(Boolean).join(" / ") || NO_VALUE],
17
+ ["Host name", server.hostname || NO_VALUE],
18
+ ["Process ID", server.pid === undefined ? NO_VALUE : String(server.pid)],
19
+ ["Started", formatDateTime(server.startedAt)],
20
+ ["Up for", formatUptime(server.uptimeSeconds)],
21
+ ];
22
+ return (
23
+ <section aria-labelledby="diagnostics-server-heading" className="rounded-md border border-border bg-surface p-4">
24
+ <h2 id="diagnostics-server-heading" className="text-base font-bold uppercase tracking-wide mb-3">
25
+ Server
26
+ </h2>
27
+ <dl className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
28
+ {rows.map(([term, description]) => (
29
+ <div key={term}>
30
+ <dt className="text-xs uppercase tracking-wide text-text-muted">{term}</dt>
31
+ <dd className="break-words">{description}</dd>
32
+ </div>
33
+ ))}
34
+ </dl>
35
+ </section>
36
+ );
37
+ }
@@ -0,0 +1,79 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { METRICS_HISTORY_SIZE } from "./metricsHistory.js";
7
+
8
+ const WIDTH = 240;
9
+ const HEIGHT = 48;
10
+ /** Keeps a line at the very top or bottom of the plot from being clipped by its own 2px stroke. */
11
+ const PADDING = 2;
12
+
13
+ /**
14
+ * Where each value is drawn, as `[x, y]` in the plot's own coordinates. The x-axis always has room for a full
15
+ * history (`METRICS_HISTORY_SIZE` samples), with the newest sample at the right edge, so a line that has just started
16
+ * grows in from the right rather than being stretched across the plot. The y-axis runs from zero to `max` (the highest
17
+ * value when it is not given), so the shape is never a lie about how much the value moves.
18
+ */
19
+ export function sparklinePoints(values: number[], max?: number): [number, number][] {
20
+ const top = max !== undefined && max > 0 ? max : Math.max(...values, 0) || 1;
21
+ const step = WIDTH / (METRICS_HISTORY_SIZE - 1);
22
+ return values.map((value, index) => {
23
+ const x = WIDTH - (values.length - 1 - index) * step;
24
+ const y = HEIGHT - PADDING - (Math.min(Math.max(value, 0), top) / top) * (HEIGHT - 2 * PADDING);
25
+ return [x, y];
26
+ });
27
+ }
28
+
29
+ export interface SparklineProps {
30
+ values: number[];
31
+ /** What is drawn, for the text alternative (`CPU used`). */
32
+ label: string;
33
+ /** The value the plot's top stands for. Defaults to the highest value seen. */
34
+ max?: number;
35
+ /** Writes a value for the text alternative. */
36
+ format: (value: number) => string;
37
+ }
38
+
39
+ /**
40
+ * A line with a wash of the same colour under it, over the last few minutes of a value. It carries no numbers itself: the
41
+ * value beside it does, and the text alternative says the latest, lowest and highest.
42
+ */
43
+ export default function Sparkline({ values, label, max, format }: SparklineProps) {
44
+ const points = sparklinePoints(values, max);
45
+ let description = `${label}: collecting samples`;
46
+ if (values.length > 1) {
47
+ description = `${label}: latest ${format(values[values.length - 1])}, lowest ${format(Math.min(...values))}, highest ${format(Math.max(...values))} over the last ${values.length} samples`;
48
+ }
49
+ const line = points.map(([x, y], index) => `${index === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
50
+ return (
51
+ <svg
52
+ role="img"
53
+ aria-label={description}
54
+ viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
55
+ preserveAspectRatio="none"
56
+ className="block h-12 w-full"
57
+ >
58
+ <title>{description}</title>
59
+ <line x1="0" y1={HEIGHT - 0.5} x2={WIDTH} y2={HEIGHT - 0.5} className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />
60
+ {points.length > 1 && (
61
+ <>
62
+ <path
63
+ d={`${line} L${points[points.length - 1][0].toFixed(1)} ${HEIGHT} L${points[0][0].toFixed(1)} ${HEIGHT} Z`}
64
+ className="fill-primary/10"
65
+ />
66
+ <path
67
+ d={line}
68
+ fill="none"
69
+ className="stroke-primary"
70
+ strokeWidth="2"
71
+ strokeLinejoin="round"
72
+ strokeLinecap="round"
73
+ vectorEffect="non-scaling-stroke"
74
+ />
75
+ </>
76
+ )}
77
+ </svg>
78
+ );
79
+ }
@@ -0,0 +1,81 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlinePause, HiOutlinePlay } from "react-icons/hi2";
7
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
9
+ import { formatDateTime } from "./format.js";
10
+ import HostCard from "./HostCard.js";
11
+ import KubernetesNotice from "./KubernetesNotice.js";
12
+ import { METRICS_POLL_INTERVAL_MS } from "./metricsHistory.js";
13
+ import NamespaceCard from "./NamespaceCard.js";
14
+ import ProcessCard from "./ProcessCard.js";
15
+ import PvcTable from "./PvcTable.js";
16
+ import type { MetricsPolling } from "./useMetricsPolling.js";
17
+
18
+ /** The System tab: live resource use, refreshed every few seconds, with the last few minutes drawn as sparklines. */
19
+ export default function SystemPanel({ polling }: { polling: MetricsPolling }) {
20
+ const { history, error, loading, paused, setPaused } = polling;
21
+ const latest = history[history.length - 1];
22
+ const kubernetes = latest?.kubernetes;
23
+ const errors = kubernetes?.errors ?? [];
24
+ let status = `Updates every ${METRICS_POLL_INTERVAL_MS / 1000} seconds while this page is visible.`;
25
+ if (paused) {
26
+ status = "Updates are paused.";
27
+ } else if (loading) {
28
+ status = "Loading…";
29
+ }
30
+ return (
31
+ <div className="space-y-6">
32
+ <div className="flex flex-wrap items-center gap-3">
33
+ <Button
34
+ type="button"
35
+ variant="secondary"
36
+ className="!w-auto"
37
+ aria-pressed={paused}
38
+ onClick={() => setPaused(!paused)}
39
+ >
40
+ {paused ? (
41
+ <>
42
+ <HiOutlinePlay size={16} aria-hidden="true" className="mr-1 inline" />
43
+ Resume
44
+ </>
45
+ ) : (
46
+ <>
47
+ <HiOutlinePause size={16} aria-hidden="true" className="mr-1 inline" />
48
+ Pause
49
+ </>
50
+ )}
51
+ </Button>
52
+ <p role="status" className="text-xs text-text-muted">
53
+ {status}
54
+ {latest && ` Last sample ${formatDateTime(latest.collectedAt)}.`}
55
+ </p>
56
+ </div>
57
+ {error && <Alert>{error}</Alert>}
58
+ {latest && (
59
+ <>
60
+ <HostCard host={latest.host} history={history} />
61
+ <ProcessCard process={latest.process} history={history} />
62
+ {!kubernetes?.available ? (
63
+ <KubernetesNotice reason={kubernetes?.reason} />
64
+ ) : (
65
+ <>
66
+ {errors.length > 0 && (
67
+ <Alert>
68
+ {errors.map((message) => (
69
+ <div key={message}>{message}</div>
70
+ ))}
71
+ </Alert>
72
+ )}
73
+ <NamespaceCard namespace={kubernetes.namespace} history={history} />
74
+ <PvcTable pvcs={kubernetes.pvcs ?? []} />
75
+ </>
76
+ )}
77
+ </>
78
+ )}
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,73 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { HiOutlineExclamationCircle, HiOutlineExclamationTriangle } from "react-icons/hi2";
7
+ import { formatPercent } from "./format.js";
8
+
9
+ /** Above this share of its capacity, a resource is called high. */
10
+ export const HIGH_USAGE_PERCENT = 80;
11
+
12
+ /** Above this share of its capacity, a resource is called critical. */
13
+ export const CRITICAL_USAGE_PERCENT = 90;
14
+
15
+ export type UsageSeverity = "normal" | "high" | "critical";
16
+
17
+ export function usageSeverity(percent: number): UsageSeverity {
18
+ if (percent > CRITICAL_USAGE_PERCENT) {
19
+ return "critical";
20
+ }
21
+ return percent > HIGH_USAGE_PERCENT ? "high" : "normal";
22
+ }
23
+
24
+ /** The fill of the bar for each severity: the ordinary accent, then the warning and danger colours. */
25
+ const FILL: Record<UsageSeverity, string> = { normal: "bg-primary", high: "bg-warning", critical: "bg-danger" };
26
+
27
+ export interface UsageMeterProps {
28
+ /** What the bar measures (`Data volume usage`), for its text alternative. */
29
+ label: string;
30
+ /** How much of the capacity is used, 0 to 100 (a value outside is clamped for the bar, and written as it is). */
31
+ percent: number;
32
+ /** The amounts the percentage comes from (`3.2 GiB of 8 GiB`). */
33
+ detail: string;
34
+ /**
35
+ * Whether a high value is flagged (default). Off for a figure that is not the fullness of something of its own, such as a
36
+ * volume that shares the node's disk, where "nearly full" would be a false alarm.
37
+ */
38
+ flag?: boolean;
39
+ }
40
+
41
+ /**
42
+ * A bar showing how full something is. The amounts and the percentage are always written beside it, and a bar over
43
+ * `HIGH_USAGE_PERCENT` or `CRITICAL_USAGE_PERCENT` is also worded (High, Critical) with an icon, so the state is never
44
+ * only a colour.
45
+ */
46
+ export default function UsageMeter({ label, percent, detail, flag = true }: UsageMeterProps) {
47
+ const severity = flag ? usageSeverity(percent) : "normal";
48
+ const width = Math.min(Math.max(percent, 0), 100);
49
+ return (
50
+ <div>
51
+ <div className="flex items-center justify-between gap-2 text-sm">
52
+ <span className="tabular-nums">{detail}</span>
53
+ <span className="flex items-center gap-1 tabular-nums">
54
+ {severity === "high" && <HiOutlineExclamationTriangle size={16} aria-hidden="true" className="text-warning" />}
55
+ {severity === "critical" && <HiOutlineExclamationCircle size={16} aria-hidden="true" className="text-danger" />}
56
+ {formatPercent(percent)}
57
+ {severity !== "normal" && <span className="font-medium">{severity === "high" ? "High" : "Critical"}</span>}
58
+ </span>
59
+ </div>
60
+ <div
61
+ role="meter"
62
+ aria-label={label}
63
+ aria-valuemin={0}
64
+ aria-valuemax={100}
65
+ aria-valuenow={Math.round(width)}
66
+ aria-valuetext={`${detail}, ${formatPercent(percent)}${severity === "normal" ? "" : `, ${severity}`}`}
67
+ className="mt-1 h-2 overflow-hidden rounded-pill border border-border bg-surface-alt"
68
+ >
69
+ <div className={`h-full rounded-pill ${FILL[severity]}`} style={{ width: `${width}%` }} />
70
+ </div>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,36 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
7
+ import type { DiagnosticsVersions } from "./diagnosticsApi.js";
8
+ import type { DiagnosticsResource } from "./useDiagnosticsResource.js";
9
+ import ComponentsTable from "./ComponentsTable.js";
10
+ import InstalledPlugins, { InstalledPluginsData } from "./InstalledPlugins.js";
11
+ import PackagesTable from "./PackagesTable.js";
12
+ import ServerVersionCard from "./ServerVersionCard.js";
13
+
14
+ export interface VersionsPanelProps {
15
+ versions: DiagnosticsResource<DiagnosticsVersions>;
16
+ plugins: DiagnosticsResource<InstalledPluginsData>;
17
+ }
18
+
19
+ /** The Versions tab: the server, its packages, the installed plugins and the install's other containers. */
20
+ export default function VersionsPanel({ versions, plugins }: VersionsPanelProps) {
21
+ const { data, error, loading } = versions;
22
+ return (
23
+ <div className="space-y-6">
24
+ {error && <Alert>{error}</Alert>}
25
+ {!data && loading && <p className="text-sm text-text-muted">Loading&hellip;</p>}
26
+ {data && (
27
+ <>
28
+ <ServerVersionCard server={data.server} />
29
+ <ComponentsTable components={data.components ?? []} kubernetes={data.kubernetes ?? { available: false }} />
30
+ <InstalledPlugins data={plugins.data} error={plugins.error} loading={plugins.loading} />
31
+ <PackagesTable packages={data.packages ?? []} />
32
+ </>
33
+ )}
34
+ </div>
35
+ );
36
+ }