@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,15 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
7
|
+
import DiagnosticsManager from "../../shared/components/admin/diagnostics/DiagnosticsManager.js";
|
|
8
|
+
|
|
9
|
+
export default function DiagnosticsPage(props: Omit<AdminShellProps, "active">) {
|
|
10
|
+
return (
|
|
11
|
+
<AdminShell {...props} active="diagnostics">
|
|
12
|
+
<DiagnosticsManager />
|
|
13
|
+
</AdminShell>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode } from "react";
|
|
6
|
+
import type { IconType } from "react-icons";
|
|
7
|
+
|
|
8
|
+
export type BadgeTone = "success" | "warning" | "danger" | "info" | "neutral";
|
|
9
|
+
|
|
10
|
+
/** The tint behind a badge and the colour of its icon. The words stay in the normal text colour, and every tone
|
|
11
|
+
* that means something (good, warning, bad) is also worded, so a badge never relies on its colour alone. */
|
|
12
|
+
const TONES: Record<BadgeTone, { background: string; icon: string }> = {
|
|
13
|
+
success: { background: "bg-success/15", icon: "text-success" },
|
|
14
|
+
warning: { background: "bg-warning/25", icon: "text-warning" },
|
|
15
|
+
danger: { background: "bg-danger/15", icon: "text-danger" },
|
|
16
|
+
info: { background: "bg-primary/15", icon: "text-primary" },
|
|
17
|
+
neutral: { background: "bg-surface-alt", icon: "text-text-muted" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface BadgeProps {
|
|
21
|
+
tone?: BadgeTone;
|
|
22
|
+
icon?: IconType;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A small pill: an optional icon, then a word or two. */
|
|
27
|
+
export default function Badge({ tone = "neutral", icon: Icon, children }: BadgeProps) {
|
|
28
|
+
const classes = TONES[tone];
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
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}`}
|
|
32
|
+
>
|
|
33
|
+
{Icon && <Icon size={14} aria-hidden="true" className={classes.icon} />}
|
|
34
|
+
{children}
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { HiOutlineCheckCircle, HiOutlineExclamationTriangle, HiOutlineMinusCircle, HiOutlineQuestionMarkCircle } from "react-icons/hi2";
|
|
7
|
+
import type { DiagnosticsComponent, DiagnosticsContainer, DiagnosticsPod } from "./diagnosticsApi.js";
|
|
8
|
+
import Badge from "./Badge.js";
|
|
9
|
+
import KubernetesNotice from "./KubernetesNotice.js";
|
|
10
|
+
import { formatDateTime, NO_VALUE, shortDigest } from "./format.js";
|
|
11
|
+
|
|
12
|
+
const STATUS: Record<DiagnosticsComponent["status"], { label: string; tone: "success" | "warning" | "neutral"; icon: typeof HiOutlineCheckCircle }> = {
|
|
13
|
+
running: { label: "Running", tone: "success", icon: HiOutlineCheckCircle },
|
|
14
|
+
"not-ready": { label: "Not ready", tone: "warning", icon: HiOutlineExclamationTriangle },
|
|
15
|
+
missing: { label: "Not found", tone: "neutral", icon: HiOutlineMinusCircle },
|
|
16
|
+
unknown: { label: "Unknown", tone: "neutral", icon: HiOutlineQuestionMarkCircle },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The container to describe a component by: the one running the image tag the server calls its version, else the first. */
|
|
20
|
+
export function mainContainer(component: DiagnosticsComponent): DiagnosticsContainer | undefined {
|
|
21
|
+
const containers = component.pods.flatMap((pod) => pod.containers);
|
|
22
|
+
return containers.find((container) => container.tag !== undefined && container.tag === component.version) ?? containers[0];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `image` with its tag, unless the server already wrote the tag into the image. */
|
|
26
|
+
export function imageReference(container: DiagnosticsContainer): string {
|
|
27
|
+
return container.tag && !container.image.endsWith(`:${container.tag}`) ? `${container.image}:${container.tag}` : container.image;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HEADINGS = ["Component", "Status", "Version", "Image", "Digest", "Restarts", "Node"];
|
|
31
|
+
|
|
32
|
+
export interface ComponentsTableProps {
|
|
33
|
+
components: DiagnosticsComponent[];
|
|
34
|
+
kubernetes: { available: boolean; reason?: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The other containers of the install (mail transport, databases, the spam and virus filters, the TURN relay) and how they are doing. */
|
|
38
|
+
export default function ComponentsTable({ components, kubernetes }: ComponentsTableProps) {
|
|
39
|
+
return (
|
|
40
|
+
<section aria-labelledby="diagnostics-components-heading" className="rounded-md border border-border bg-surface p-4">
|
|
41
|
+
<h2 id="diagnostics-components-heading" className="text-base font-bold uppercase tracking-wide mb-3">
|
|
42
|
+
Other containers
|
|
43
|
+
</h2>
|
|
44
|
+
{!kubernetes.available ? (
|
|
45
|
+
<KubernetesNotice reason={kubernetes.reason} />
|
|
46
|
+
) : (
|
|
47
|
+
<div className="overflow-x-auto">
|
|
48
|
+
<table className="w-full text-sm border-collapse">
|
|
49
|
+
<thead>
|
|
50
|
+
<tr>
|
|
51
|
+
{HEADINGS.map((heading) => (
|
|
52
|
+
<th
|
|
53
|
+
key={heading}
|
|
54
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
55
|
+
>
|
|
56
|
+
{heading}
|
|
57
|
+
</th>
|
|
58
|
+
))}
|
|
59
|
+
</tr>
|
|
60
|
+
</thead>
|
|
61
|
+
<tbody>
|
|
62
|
+
{components.map((component) => (
|
|
63
|
+
<ComponentRow key={component.component} component={component} />
|
|
64
|
+
))}
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ComponentRow({ component }: { component: DiagnosticsComponent }) {
|
|
74
|
+
const status = STATUS[component.status] ?? STATUS.unknown;
|
|
75
|
+
const main = mainContainer(component);
|
|
76
|
+
const restarts = component.pods.reduce((total, pod) => total + pod.restarts, 0);
|
|
77
|
+
const nodes = [...new Set(component.pods.map((pod) => pod.node).filter(Boolean))];
|
|
78
|
+
return (
|
|
79
|
+
<tr className="align-top">
|
|
80
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
81
|
+
<div className="font-medium">{component.component}</div>
|
|
82
|
+
{component.pods.length > 0 && (
|
|
83
|
+
<details className="text-xs text-text-muted">
|
|
84
|
+
<summary className="cursor-pointer">
|
|
85
|
+
{component.pods.length} {component.pods.length === 1 ? "pod" : "pods"}
|
|
86
|
+
</summary>
|
|
87
|
+
<ul className="mt-1 space-y-2">
|
|
88
|
+
{component.pods.map((pod) => (
|
|
89
|
+
<PodDetail key={pod.name} pod={pod} />
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
</details>
|
|
93
|
+
)}
|
|
94
|
+
</td>
|
|
95
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
96
|
+
<Badge tone={status.tone} icon={status.icon}>
|
|
97
|
+
{status.label}
|
|
98
|
+
</Badge>
|
|
99
|
+
</td>
|
|
100
|
+
<td className="py-2 px-2.5 border-b border-border font-mono">{component.version ?? NO_VALUE}</td>
|
|
101
|
+
<td className="py-2 px-2.5 border-b border-border font-mono break-all">{main?.image ?? NO_VALUE}</td>
|
|
102
|
+
<td className="py-2 px-2.5 border-b border-border font-mono" title={main?.digest}>
|
|
103
|
+
{shortDigest(main?.digest)}
|
|
104
|
+
</td>
|
|
105
|
+
<td className="py-2 px-2.5 border-b border-border tabular-nums">{component.pods.length > 0 ? restarts : NO_VALUE}</td>
|
|
106
|
+
<td className="py-2 px-2.5 border-b border-border">{nodes.length > 0 ? nodes.join(", ") : NO_VALUE}</td>
|
|
107
|
+
</tr>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function PodDetail({ pod }: { pod: DiagnosticsPod }) {
|
|
112
|
+
return (
|
|
113
|
+
<li className="rounded-sm border border-border p-2">
|
|
114
|
+
<div className="font-mono text-text break-all">{pod.name}</div>
|
|
115
|
+
<div>
|
|
116
|
+
{pod.phase} · {pod.ready ? "ready" : "not ready"} · {pod.restarts} {pod.restarts === 1 ? "restart" : "restarts"}
|
|
117
|
+
{pod.node ? ` · on ${pod.node}` : ""}
|
|
118
|
+
{pod.startedAt ? ` · started ${formatDateTime(pod.startedAt)}` : ""}
|
|
119
|
+
</div>
|
|
120
|
+
<ul className="mt-1">
|
|
121
|
+
{pod.containers.map((container) => (
|
|
122
|
+
<li key={container.name} className="font-mono break-all">
|
|
123
|
+
{container.name}: {imageReference(container)}
|
|
124
|
+
{container.digest ? ` @${shortDigest(container.digest)}` : ""} · {container.ready ? "ready" : "not ready"} ·{" "}
|
|
125
|
+
{container.restartCount} {container.restartCount === 1 ? "restart" : "restarts"}
|
|
126
|
+
</li>
|
|
127
|
+
))}
|
|
128
|
+
</ul>
|
|
129
|
+
</li>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { KeyboardEvent, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { HiOutlineArrowDownTray, HiOutlineArrowPath } from "react-icons/hi2";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
import { DiagnosticsMetrics, getDiagnosticsMetrics, getDiagnosticsRuntime, getDiagnosticsVersions } from "./diagnosticsApi.js";
|
|
9
|
+
import { saveTextFile, SaveFile, timestampedFilename } from "./download.js";
|
|
10
|
+
import { describeError } from "./format.js";
|
|
11
|
+
import { loadInstalledPlugins } from "./InstalledPlugins.js";
|
|
12
|
+
import type { LogSocketFactory } from "./logClient.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
|
+
|
|
21
|
+
export type DiagnosticsTab = "versions" | "runtime" | "system" | "logs";
|
|
22
|
+
|
|
23
|
+
const TABS: { id: DiagnosticsTab; label: string }[] = [
|
|
24
|
+
{ id: "versions", label: "Versions" },
|
|
25
|
+
{ id: "runtime", label: "Runtime" },
|
|
26
|
+
{ id: "system", label: "System" },
|
|
27
|
+
{ id: "logs", label: "Logs" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const tabId = (tab: DiagnosticsTab) => `diagnostics-tab-${tab}`;
|
|
31
|
+
const panelId = (tab: DiagnosticsTab) => `diagnostics-panel-${tab}`;
|
|
32
|
+
|
|
33
|
+
export interface DiagnosticsManagerProps {
|
|
34
|
+
/** Creates the log stream's WebSocket. Defaults to the browser's. For a test. */
|
|
35
|
+
createLogSocket?: LogSocketFactory;
|
|
36
|
+
/** Saves a downloaded file. Defaults to `saveTextFile()`. For a test. */
|
|
37
|
+
saveFile?: SaveFile;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The Diagnostics page: what is installed (Versions), what it runs on (Runtime), how it is doing right now (System, live) and what
|
|
42
|
+
* the server is logging (Logs, live, with captures). Versions and Runtime are read when the page opens and again on Refresh, and
|
|
43
|
+
* 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
|
|
44
|
+
* first time the Logs tab is (and closes with the page, or on Stop).
|
|
45
|
+
*/
|
|
46
|
+
export default function DiagnosticsManager({ createLogSocket, saveFile = saveTextFile }: DiagnosticsManagerProps) {
|
|
47
|
+
const [tab, setTab] = useState<DiagnosticsTab>("versions");
|
|
48
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
49
|
+
const [preparingReport, setPreparingReport] = useState(false);
|
|
50
|
+
const versions = useDiagnosticsResource(getDiagnosticsVersions, reloadKey, "Could not read the server's versions.");
|
|
51
|
+
const runtime = useDiagnosticsResource(getDiagnosticsRuntime, reloadKey, "Could not read the runtime.");
|
|
52
|
+
const plugins = useDiagnosticsResource(loadInstalledPlugins, reloadKey, "Could not read the installed plugins.");
|
|
53
|
+
const polling = useMetricsPolling(tab === "system");
|
|
54
|
+
const logStream = useLogStream({ createSocket: createLogSocket });
|
|
55
|
+
const logsOpened = useRef(false);
|
|
56
|
+
const { start: startLogs } = logStream;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (tab === "logs" && !logsOpened.current) {
|
|
60
|
+
logsOpened.current = true;
|
|
61
|
+
startLogs();
|
|
62
|
+
}
|
|
63
|
+
}, [tab]);
|
|
64
|
+
|
|
65
|
+
const refreshing = versions.loading || runtime.loading || plugins.loading;
|
|
66
|
+
|
|
67
|
+
function handleTabKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
|
68
|
+
const index = TABS.findIndex((item) => item.id === tab);
|
|
69
|
+
let next: number;
|
|
70
|
+
if (event.key === "ArrowRight") {
|
|
71
|
+
next = (index + 1) % TABS.length;
|
|
72
|
+
} else if (event.key === "ArrowLeft") {
|
|
73
|
+
next = (index + TABS.length - 1) % TABS.length;
|
|
74
|
+
} else if (event.key === "Home") {
|
|
75
|
+
next = 0;
|
|
76
|
+
} else if (event.key === "End") {
|
|
77
|
+
next = TABS.length - 1;
|
|
78
|
+
} else {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
setTab(TABS[next].id);
|
|
83
|
+
document.getElementById(tabId(TABS[next].id))?.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Saves the versions, the runtime and the latest metrics sample (asking for one if the System tab has not sampled): no logs. */
|
|
87
|
+
async function downloadReport() {
|
|
88
|
+
setPreparingReport(true);
|
|
89
|
+
const generatedAt = new Date();
|
|
90
|
+
let metrics: DiagnosticsMetrics | undefined = polling.history[polling.history.length - 1];
|
|
91
|
+
let metricsError: string | undefined;
|
|
92
|
+
if (!metrics) {
|
|
93
|
+
try {
|
|
94
|
+
metrics = await getDiagnosticsMetrics();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
metricsError = describeError(err, "Could not read the server's metrics.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const report = {
|
|
100
|
+
generatedAt: generatedAt.toISOString(),
|
|
101
|
+
versions: versions.data ?? null,
|
|
102
|
+
runtime: runtime.data ?? null,
|
|
103
|
+
// Only what each plugin is and which version is loaded: a plugin's settings can hold secrets.
|
|
104
|
+
plugins: plugins.data
|
|
105
|
+
? {
|
|
106
|
+
installed: plugins.data.plugins.map(({ name, packageVersion, enabled }) => ({ name, packageVersion, enabled })),
|
|
107
|
+
status: plugins.data.status ?? null,
|
|
108
|
+
}
|
|
109
|
+
: null,
|
|
110
|
+
metrics: metrics ?? null,
|
|
111
|
+
errors: {
|
|
112
|
+
versions: versions.error ?? null,
|
|
113
|
+
runtime: runtime.error ?? null,
|
|
114
|
+
plugins: plugins.error ?? null,
|
|
115
|
+
metrics: metricsError ?? null,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
saveFile(timestampedFilename("rapidmx-diagnostics", "json", generatedAt), `${JSON.stringify(report, null, 2)}\n`, "application/json");
|
|
119
|
+
setPreparingReport(false);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
|
125
|
+
<h1 className="text-xl font-bold uppercase tracking-wide">Diagnostics</h1>
|
|
126
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
127
|
+
<Button
|
|
128
|
+
type="button"
|
|
129
|
+
variant="secondary"
|
|
130
|
+
className="!w-auto"
|
|
131
|
+
disabled={refreshing}
|
|
132
|
+
onClick={() => setReloadKey(reloadKey + 1)}
|
|
133
|
+
>
|
|
134
|
+
<HiOutlineArrowPath size={16} aria-hidden="true" className="mr-1 inline" />
|
|
135
|
+
Refresh
|
|
136
|
+
</Button>
|
|
137
|
+
<Button type="button" className="!w-auto" loading={preparingReport} onClick={() => void downloadReport()}>
|
|
138
|
+
<HiOutlineArrowDownTray size={16} aria-hidden="true" className="mr-1 inline" />
|
|
139
|
+
Download diagnostics report
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div role="tablist" aria-label="Diagnostics sections" className="mb-5 flex gap-1 overflow-x-auto rounded-lg bg-surface-alt p-1">
|
|
145
|
+
{TABS.map((item) => (
|
|
146
|
+
<button
|
|
147
|
+
key={item.id}
|
|
148
|
+
type="button"
|
|
149
|
+
role="tab"
|
|
150
|
+
id={tabId(item.id)}
|
|
151
|
+
aria-selected={item.id === tab}
|
|
152
|
+
aria-controls={panelId(item.id)}
|
|
153
|
+
tabIndex={item.id === tab ? 0 : -1}
|
|
154
|
+
onClick={() => setTab(item.id)}
|
|
155
|
+
onKeyDown={handleTabKeyDown}
|
|
156
|
+
className={`flex-1 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium ${
|
|
157
|
+
item.id === tab ? "bg-surface text-text shadow-card" : "text-text-muted hover:text-text"
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
{item.label}
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div role="tabpanel" id={panelId(tab)} aria-labelledby={tabId(tab)}>
|
|
166
|
+
{tab === "versions" && <VersionsPanel versions={versions} plugins={plugins} />}
|
|
167
|
+
{tab === "runtime" && <RuntimePanel runtime={runtime} />}
|
|
168
|
+
{tab === "system" && <SystemPanel polling={polling} />}
|
|
169
|
+
{tab === "logs" && <LogsPanel stream={logStream} saveFile={saveFile} />}
|
|
170
|
+
</div>
|
|
171
|
+
</>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { DiagnosticsDiskMetrics, DiagnosticsMetrics } from "./diagnosticsApi.js";
|
|
7
|
+
import { formatBytes, formatCores, formatPercent, NO_VALUE, percentOf } from "./format.js";
|
|
8
|
+
import { seriesOf } from "./metricsHistory.js";
|
|
9
|
+
import MetricTile from "./MetricTile.js";
|
|
10
|
+
import UsageMeter from "./UsageMeter.js";
|
|
11
|
+
|
|
12
|
+
const percentLabel = (value: number) => formatPercent(value);
|
|
13
|
+
|
|
14
|
+
/** `1.23, 0.98, 0.5`, or a dash for a load average the server left out. */
|
|
15
|
+
export function formatLoad(load: number[] | undefined): string {
|
|
16
|
+
return load && load.length > 0 ? load.map((value) => value.toFixed(2)).join(", ") : NO_VALUE;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HostCardProps {
|
|
20
|
+
host: DiagnosticsMetrics["host"] | undefined;
|
|
21
|
+
history: DiagnosticsMetrics[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function DiskRow({ disk }: { disk: DiagnosticsDiskMetrics }) {
|
|
25
|
+
const percent = percentOf(disk.usedBytes, disk.capacityBytes);
|
|
26
|
+
return (
|
|
27
|
+
<li className="rounded-sm border border-border p-3">
|
|
28
|
+
<div className="mb-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
|
29
|
+
<span className="font-mono break-all">{disk.path}</span>
|
|
30
|
+
{disk.pvc && <span className="text-xs text-text-muted">volume {disk.pvc}</span>}
|
|
31
|
+
</div>
|
|
32
|
+
{percent === undefined ? (
|
|
33
|
+
<p className="text-sm text-text-muted">No usage figures.</p>
|
|
34
|
+
) : (
|
|
35
|
+
<UsageMeter
|
|
36
|
+
label={`Disk usage of ${disk.path}`}
|
|
37
|
+
percent={percent}
|
|
38
|
+
detail={`${formatBytes(disk.usedBytes)} of ${formatBytes(disk.capacityBytes)}, ${formatBytes(disk.availableBytes)} free`}
|
|
39
|
+
/>
|
|
40
|
+
)}
|
|
41
|
+
{disk.sharesNodeDisk && <p className="mt-1 text-xs text-text-muted">Shares the node’s disk.</p>}
|
|
42
|
+
</li>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The node the server pod runs on, as its own container sees it: whole-host CPU, memory, load and disks. It is only that
|
|
48
|
+
* node in a cluster of several, and the whole machine on a single-node install or under Docker Compose.
|
|
49
|
+
*/
|
|
50
|
+
export default function HostCard({ host, history }: HostCardProps) {
|
|
51
|
+
const memoryPercent = percentOf(host?.memoryUsedBytes, host?.memoryTotalBytes);
|
|
52
|
+
const disks = host?.disks ?? [];
|
|
53
|
+
return (
|
|
54
|
+
<section aria-labelledby="diagnostics-host-heading" className="rounded-md border border-border bg-surface p-4">
|
|
55
|
+
<h2 id="diagnostics-host-heading" className="text-base font-bold uppercase tracking-wide mb-1">
|
|
56
|
+
Node running this server
|
|
57
|
+
</h2>
|
|
58
|
+
<p className="mb-3 text-xs text-text-muted">
|
|
59
|
+
As the server’s own container sees it. In a cluster of several nodes this is only the node the server runs on.
|
|
60
|
+
</p>
|
|
61
|
+
{!host ? (
|
|
62
|
+
<p className="text-sm text-text-muted">The server did not report the node’s figures.</p>
|
|
63
|
+
) : (
|
|
64
|
+
<>
|
|
65
|
+
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
66
|
+
<MetricTile
|
|
67
|
+
label="CPU"
|
|
68
|
+
value={formatPercent(host.cpuPercent)}
|
|
69
|
+
detail={formatCores(host.cpuCount)}
|
|
70
|
+
usage={Number.isFinite(host.cpuPercent) ? { percent: host.cpuPercent, detail: "Whole host, all cores" } : undefined}
|
|
71
|
+
history={{
|
|
72
|
+
values: seriesOf(history, (sample) => sample.host?.cpuPercent),
|
|
73
|
+
max: 100,
|
|
74
|
+
format: percentLabel,
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
<MetricTile
|
|
78
|
+
label="Memory"
|
|
79
|
+
value={formatBytes(host.memoryUsedBytes)}
|
|
80
|
+
detail={`of ${formatBytes(host.memoryTotalBytes)}`}
|
|
81
|
+
usage={
|
|
82
|
+
memoryPercent === undefined
|
|
83
|
+
? undefined
|
|
84
|
+
: { percent: memoryPercent, detail: `${formatBytes(host.memoryUsedBytes)} of ${formatBytes(host.memoryTotalBytes)}` }
|
|
85
|
+
}
|
|
86
|
+
history={{
|
|
87
|
+
values: seriesOf(history, (sample) => percentOf(sample.host?.memoryUsedBytes, sample.host?.memoryTotalBytes)),
|
|
88
|
+
max: 100,
|
|
89
|
+
format: percentLabel,
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
<MetricTile label="Load average" value={formatLoad(host.loadAverage)} detail="1, 5 and 15 minutes" />
|
|
93
|
+
</div>
|
|
94
|
+
<h3 className="mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted">Disks</h3>
|
|
95
|
+
{disks.length === 0 ? (
|
|
96
|
+
<p className="text-sm text-text-muted">No disks were reported.</p>
|
|
97
|
+
) : (
|
|
98
|
+
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
99
|
+
{disks.map((disk) => (
|
|
100
|
+
<DiskRow key={disk.path} disk={disk} />
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
)}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</section>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { HiOutlineExclamationTriangle } from "react-icons/hi2";
|
|
7
|
+
import { getPluginStatus, listPlugins, Plugin, PluginStatus } from "@rapidmx/react-shared/admin/pluginsApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Badge from "./Badge.js";
|
|
10
|
+
import { NO_VALUE } from "./format.js";
|
|
11
|
+
|
|
12
|
+
/** The installed plugins, and what each server copy reports of them (absent when the status could not be read). */
|
|
13
|
+
export interface InstalledPluginsData {
|
|
14
|
+
plugins: Plugin[];
|
|
15
|
+
status: PluginStatus | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reads the plugins, and their load status. A status that cannot be read leaves the list without it, not without the list. */
|
|
19
|
+
export async function loadInstalledPlugins(): Promise<InstalledPluginsData> {
|
|
20
|
+
const [plugins, status] = await Promise.all([listPlugins(), getPluginStatus().catch(() => undefined)]);
|
|
21
|
+
return { plugins, status };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Which versions of `name` the server copies have loaded, and the load errors they report for it. */
|
|
25
|
+
export function pluginLoadState(status: PluginStatus, name: string) {
|
|
26
|
+
const instances = status.instances;
|
|
27
|
+
const loaded = instances.flatMap((instance) => instance.loaded.filter((item) => item.name === name));
|
|
28
|
+
const errors = instances.flatMap((instance) => instance.errors.filter((item) => item.name === name));
|
|
29
|
+
return {
|
|
30
|
+
versions: [...new Set(loaded.map((item) => item.version))],
|
|
31
|
+
loadedOn: loaded.length,
|
|
32
|
+
instanceCount: instances.length,
|
|
33
|
+
errors: [...new Set(errors.map((item) => item.message))],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HEADINGS = ["Plugin", "Configured version", "Enabled", "Loaded version", "Load errors"];
|
|
38
|
+
|
|
39
|
+
export interface InstalledPluginsProps {
|
|
40
|
+
data: InstalledPluginsData | undefined;
|
|
41
|
+
error: string | undefined;
|
|
42
|
+
loading: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Each installed plugin with the version configured for it and the version the running servers actually loaded. The two
|
|
47
|
+
* differ while a change is still being applied, or when a version could not be loaded (then the load error is shown).
|
|
48
|
+
*/
|
|
49
|
+
export default function InstalledPlugins({ data, error, loading }: InstalledPluginsProps) {
|
|
50
|
+
let body;
|
|
51
|
+
if (error) {
|
|
52
|
+
body = <Alert>{error}</Alert>;
|
|
53
|
+
} else if (!data) {
|
|
54
|
+
body = loading ? <p className="text-sm text-text-muted">Loading…</p> : null;
|
|
55
|
+
} else if (data.plugins.length === 0) {
|
|
56
|
+
body = <p className="text-sm text-text-muted">No plugins are installed.</p>;
|
|
57
|
+
} else {
|
|
58
|
+
body = (
|
|
59
|
+
<>
|
|
60
|
+
{!data.status && (
|
|
61
|
+
<p role="status" className="mb-2 text-xs text-text-muted">
|
|
62
|
+
What the servers have loaded could not be read, so only the configured versions are shown.
|
|
63
|
+
</p>
|
|
64
|
+
)}
|
|
65
|
+
<div className="overflow-x-auto">
|
|
66
|
+
<table className="w-full text-sm border-collapse">
|
|
67
|
+
<thead>
|
|
68
|
+
<tr>
|
|
69
|
+
{HEADINGS.map((heading) => (
|
|
70
|
+
<th
|
|
71
|
+
key={heading}
|
|
72
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
73
|
+
>
|
|
74
|
+
{heading}
|
|
75
|
+
</th>
|
|
76
|
+
))}
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
{data.plugins.map((plugin) => (
|
|
81
|
+
<PluginRow key={plugin.uid} plugin={plugin} status={data.status} />
|
|
82
|
+
))}
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<section aria-labelledby="diagnostics-plugins-heading" className="rounded-md border border-border bg-surface p-4">
|
|
91
|
+
<h2 id="diagnostics-plugins-heading" className="text-base font-bold uppercase tracking-wide mb-3">
|
|
92
|
+
Installed plugins
|
|
93
|
+
</h2>
|
|
94
|
+
{body}
|
|
95
|
+
</section>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function PluginRow({ plugin, status }: { plugin: Plugin; status: PluginStatus | undefined }) {
|
|
100
|
+
const state = status ? pluginLoadState(status, plugin.name) : undefined;
|
|
101
|
+
let loaded = NO_VALUE;
|
|
102
|
+
if (state) {
|
|
103
|
+
if (state.versions.length > 0) {
|
|
104
|
+
loaded = `${state.versions.join(", ")} (on ${state.loadedOn} of ${state.instanceCount} servers)`;
|
|
105
|
+
} else if (plugin.enabled) {
|
|
106
|
+
loaded = "Not loaded";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const differs = state !== undefined && state.versions.some((version) => version !== plugin.packageVersion);
|
|
110
|
+
return (
|
|
111
|
+
<tr>
|
|
112
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
113
|
+
<div>{plugin.manifest.displayName ?? plugin.name}</div>
|
|
114
|
+
<div className="font-mono text-xs text-text-muted break-all">{plugin.name}</div>
|
|
115
|
+
</td>
|
|
116
|
+
<td className="py-2 px-2.5 border-b border-border font-mono">{plugin.packageVersion}</td>
|
|
117
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
118
|
+
<Badge tone={plugin.enabled ? "success" : "neutral"}>{plugin.enabled ? "Enabled" : "Disabled"}</Badge>
|
|
119
|
+
</td>
|
|
120
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
121
|
+
<span className="font-mono">{loaded}</span>
|
|
122
|
+
{differs && <div className="text-xs text-text-muted">Differs from the configured version</div>}
|
|
123
|
+
</td>
|
|
124
|
+
<td className="py-2 px-2.5 border-b border-border">
|
|
125
|
+
{state && state.errors.length > 0
|
|
126
|
+
? state.errors.map((message) => (
|
|
127
|
+
<div key={message} className="flex items-start gap-1">
|
|
128
|
+
<HiOutlineExclamationTriangle size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-danger" />
|
|
129
|
+
<span className="text-text break-words">{message}</span>
|
|
130
|
+
</div>
|
|
131
|
+
))
|
|
132
|
+
: NO_VALUE}
|
|
133
|
+
</td>
|
|
134
|
+
</tr>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { HiOutlineInformationCircle } from "react-icons/hi2";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Said where Kubernetes information would be, when the server cannot reach a cluster. That is ordinary (for one, when
|
|
10
|
+
* RapidMX runs under Docker Compose), so it is a plain note and not an error.
|
|
11
|
+
*/
|
|
12
|
+
export default function KubernetesNotice({ reason }: { reason?: string }) {
|
|
13
|
+
return (
|
|
14
|
+
<div role="status" className="flex items-start gap-3 rounded-md border border-border bg-surface-alt p-4 text-sm">
|
|
15
|
+
<HiOutlineInformationCircle size={20} aria-hidden="true" className="mt-0.5 shrink-0 text-text-muted" />
|
|
16
|
+
<div>
|
|
17
|
+
<p className="font-medium">Kubernetes information is not available on this server.</p>
|
|
18
|
+
{reason && <p className="mt-1 text-text-muted">{reason}</p>}
|
|
19
|
+
<p className="mt-1 text-text-muted">
|
|
20
|
+
This is expected when RapidMX does not run in a Kubernetes cluster, for example under Docker Compose.
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|