@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.
Files changed (96) hide show
  1. package/apps/admin/diagnostics/index.tsx +15 -0
  2. package/apps/shared/components/admin/diagnostics/Badge.tsx +37 -0
  3. package/apps/shared/components/admin/diagnostics/ComponentsTable.tsx +131 -0
  4. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +173 -0
  5. package/apps/shared/components/admin/diagnostics/HostCard.tsx +108 -0
  6. package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +136 -0
  7. package/apps/shared/components/admin/diagnostics/KubernetesNotice.tsx +25 -0
  8. package/apps/shared/components/admin/diagnostics/LogStore.ts +75 -0
  9. package/apps/shared/components/admin/diagnostics/LogViewer.tsx +87 -0
  10. package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +235 -0
  11. package/apps/shared/components/admin/diagnostics/MetricTile.tsx +43 -0
  12. package/apps/shared/components/admin/diagnostics/NamespaceCard.tsx +95 -0
  13. package/apps/shared/components/admin/diagnostics/PackagesTable.tsx +107 -0
  14. package/apps/shared/components/admin/diagnostics/ProcessCard.tsx +70 -0
  15. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +79 -0
  16. package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +110 -0
  17. package/apps/shared/components/admin/diagnostics/ServerVersionCard.tsx +37 -0
  18. package/apps/shared/components/admin/diagnostics/Sparkline.tsx +79 -0
  19. package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +81 -0
  20. package/apps/shared/components/admin/diagnostics/UsageMeter.tsx +73 -0
  21. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +36 -0
  22. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +197 -0
  23. package/apps/shared/components/admin/diagnostics/download.ts +28 -0
  24. package/apps/shared/components/admin/diagnostics/format.ts +106 -0
  25. package/apps/shared/components/admin/diagnostics/logClient.ts +231 -0
  26. package/apps/shared/components/admin/diagnostics/logLines.ts +125 -0
  27. package/apps/shared/components/admin/diagnostics/metricsHistory.ts +28 -0
  28. package/apps/shared/components/admin/diagnostics/useDiagnosticsResource.ts +44 -0
  29. package/apps/shared/components/admin/diagnostics/useLogStream.ts +122 -0
  30. package/apps/shared/components/admin/diagnostics/useMetricsPolling.ts +84 -0
  31. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -0
  32. package/apps/shared/components/admin/settings/PluginsManager.tsx +87 -12
  33. package/dist/apps/admin/diagnostics/index.d.ts +3 -0
  34. package/dist/apps/admin/diagnostics/index.js +6 -0
  35. package/dist/apps/shared/components/admin/diagnostics/Badge.d.ts +10 -0
  36. package/dist/apps/shared/components/admin/diagnostics/Badge.js +15 -0
  37. package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.d.ts +15 -0
  38. package/dist/apps/shared/components/admin/diagnostics/ComponentsTable.js +35 -0
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +17 -0
  40. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +111 -0
  41. package/dist/apps/shared/components/admin/diagnostics/HostCard.d.ts +13 -0
  42. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +33 -0
  43. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.d.ts +26 -0
  44. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +60 -0
  45. package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.d.ts +8 -0
  46. package/dist/apps/shared/components/admin/diagnostics/KubernetesNotice.js +9 -0
  47. package/dist/apps/shared/components/admin/diagnostics/LogStore.d.ts +37 -0
  48. package/dist/apps/shared/components/admin/diagnostics/LogStore.js +50 -0
  49. package/dist/apps/shared/components/admin/diagnostics/LogViewer.d.ts +24 -0
  50. package/dist/apps/shared/components/admin/diagnostics/LogViewer.js +47 -0
  51. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.d.ts +13 -0
  52. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +63 -0
  53. package/dist/apps/shared/components/admin/diagnostics/MetricTile.d.ts +21 -0
  54. package/dist/apps/shared/components/admin/diagnostics/MetricTile.js +7 -0
  55. package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.d.ts +13 -0
  56. package/dist/apps/shared/components/admin/diagnostics/NamespaceCard.js +15 -0
  57. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.d.ts +8 -0
  58. package/dist/apps/shared/components/admin/diagnostics/PackagesTable.js +31 -0
  59. package/dist/apps/shared/components/admin/diagnostics/ProcessCard.d.ts +8 -0
  60. package/dist/apps/shared/components/admin/diagnostics/ProcessCard.js +18 -0
  61. package/dist/apps/shared/components/admin/diagnostics/PvcTable.d.ts +6 -0
  62. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +18 -0
  63. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.d.ts +7 -0
  64. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +28 -0
  65. package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.d.ts +6 -0
  66. package/dist/apps/shared/components/admin/diagnostics/ServerVersionCard.js +17 -0
  67. package/dist/apps/shared/components/admin/diagnostics/Sparkline.d.ts +22 -0
  68. package/dist/apps/shared/components/admin/diagnostics/Sparkline.js +34 -0
  69. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.d.ts +6 -0
  70. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +26 -0
  71. package/dist/apps/shared/components/admin/diagnostics/UsageMeter.d.ts +26 -0
  72. package/dist/apps/shared/components/admin/diagnostics/UsageMeter.js +25 -0
  73. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +10 -0
  74. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +11 -0
  75. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +168 -0
  76. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +17 -0
  77. package/dist/apps/shared/components/admin/diagnostics/download.d.ts +9 -0
  78. package/dist/apps/shared/components/admin/diagnostics/download.js +23 -0
  79. package/dist/apps/shared/components/admin/diagnostics/format.d.ts +24 -0
  80. package/dist/apps/shared/components/admin/diagnostics/format.js +91 -0
  81. package/dist/apps/shared/components/admin/diagnostics/logClient.d.ts +85 -0
  82. package/dist/apps/shared/components/admin/diagnostics/logClient.js +173 -0
  83. package/dist/apps/shared/components/admin/diagnostics/logLines.d.ts +47 -0
  84. package/dist/apps/shared/components/admin/diagnostics/logLines.js +86 -0
  85. package/dist/apps/shared/components/admin/diagnostics/metricsHistory.d.ts +9 -0
  86. package/dist/apps/shared/components/admin/diagnostics/metricsHistory.js +19 -0
  87. package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.d.ts +12 -0
  88. package/dist/apps/shared/components/admin/diagnostics/useDiagnosticsResource.js +30 -0
  89. package/dist/apps/shared/components/admin/diagnostics/useLogStream.d.ts +37 -0
  90. package/dist/apps/shared/components/admin/diagnostics/useLogStream.js +79 -0
  91. package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.d.ts +20 -0
  92. package/dist/apps/shared/components/admin/diagnostics/useMetricsPolling.js +69 -0
  93. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  94. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  95. package/dist/apps/shared/components/admin/settings/PluginsManager.js +61 -13
  96. package/package.json +2 -2
@@ -0,0 +1,197 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { apiFetch } from "@rapidmx/react-shared/util/api.js";
6
+
7
+ /**
8
+ * The client of the server's `/admin/diagnostics/*` endpoints (`versions`, `runtime`, `metrics`). All three are GET,
9
+ * trusted-administrator only and `@RequiresElevation()`: an elevated token is required (403 `api-104`, see
10
+ * `../elevation.ts`), and any other 403 means the caller is not an administrator. The server may leave any optional
11
+ * field out, so a caller reads them defensively.
12
+ *
13
+ * The server reads Kubernetes with namespace-scoped permissions only: it can list the install's own pods, but not the
14
+ * cluster's nodes or the kubelet's statistics. That is why a node here is only what the pods say of it, and why the
15
+ * machine figures are those of the node running the server, read from inside its own container.
16
+ */
17
+
18
+ /** The components of a RapidMX install that run beside the server itself, in the order the server lists them. */
19
+ export type DiagnosticsComponentName =
20
+ | "postfix"
21
+ | "postfix-bridge"
22
+ | "mongodb"
23
+ | "postgresql"
24
+ | "redis"
25
+ | "coturn"
26
+ | "rspamd"
27
+ | "clamav";
28
+
29
+ export interface DiagnosticsContainer {
30
+ name: string;
31
+ image: string;
32
+ tag?: string;
33
+ digest?: string;
34
+ ready: boolean;
35
+ restartCount: number;
36
+ }
37
+
38
+ export interface DiagnosticsPod {
39
+ name: string;
40
+ phase: string;
41
+ ready: boolean;
42
+ restarts: number;
43
+ node?: string;
44
+ startedAt?: string;
45
+ containers: DiagnosticsContainer[];
46
+ }
47
+
48
+ export interface DiagnosticsComponent {
49
+ component: DiagnosticsComponentName;
50
+ /** `missing`: Kubernetes is reachable but no pod for it exists. `unknown`: Kubernetes is unavailable. */
51
+ status: "running" | "not-ready" | "missing" | "unknown";
52
+ /** The image tag of the component's main container. */
53
+ version?: string;
54
+ pods: DiagnosticsPod[];
55
+ }
56
+
57
+ export interface DiagnosticsVersions {
58
+ server: {
59
+ nodeVersion: string;
60
+ v8Version: string;
61
+ packageName: string;
62
+ packageVersion: string;
63
+ platform: string;
64
+ arch: string;
65
+ hostname: string;
66
+ pid: number;
67
+ startedAt: string;
68
+ uptimeSeconds: number;
69
+ nodeEnv: string;
70
+ };
71
+ /** Sorted by name. */
72
+ packages: { name: string; version: string; direct: boolean }[];
73
+ /** Always all eight, in the order of `DiagnosticsComponentName`. */
74
+ components: DiagnosticsComponent[];
75
+ kubernetes: { available: boolean; reason?: string };
76
+ }
77
+
78
+ /** A node that runs some of this install's pods, derived from the pods: nothing else about the node is readable. */
79
+ export interface DiagnosticsNode {
80
+ name: string;
81
+ internalIP?: string;
82
+ /** How many of the install's pods run on it. */
83
+ podCount: number;
84
+ }
85
+
86
+ export type KubernetesDistribution = "k3s" | "rke2" | "eks" | "gke" | "aks" | "kubernetes";
87
+
88
+ export interface DiagnosticsRuntime {
89
+ available: boolean;
90
+ reason?: string;
91
+ namespace?: string;
92
+ version?: {
93
+ gitVersion: string;
94
+ major: string;
95
+ minor: string;
96
+ platform: string;
97
+ goVersion?: string;
98
+ buildDate?: string;
99
+ distribution: KubernetesDistribution;
100
+ };
101
+ nodes: DiagnosticsNode[];
102
+ }
103
+
104
+ /** One filesystem of the node that runs the server, as the server's own container sees it. */
105
+ export interface DiagnosticsDiskMetrics {
106
+ path: string;
107
+ /** The volume claim mounted there, when it is one. */
108
+ pvc?: string;
109
+ usedBytes: number;
110
+ capacityBytes: number;
111
+ availableBytes: number;
112
+ /** The figures are the whole node filesystem's, not a volume of its own (a hostPath-style volume, such as k3s local-path). */
113
+ sharesNodeDisk?: boolean;
114
+ }
115
+
116
+ /**
117
+ * The node the server pod runs on, as seen from inside the server's own container (on a single-node k3s that is the whole
118
+ * machine). In a cluster of several nodes it is only that one node.
119
+ */
120
+ export interface DiagnosticsHostMetrics {
121
+ /** The whole host, 0 to 100 across all cores. */
122
+ cpuPercent: number;
123
+ cpuCount: number;
124
+ memoryTotalBytes: number;
125
+ memoryUsedBytes: number;
126
+ loadAverage: [number, number, number];
127
+ disks: DiagnosticsDiskMetrics[];
128
+ }
129
+
130
+ /** From the metrics API (metrics-server): absent when it is not installed. */
131
+ export interface DiagnosticsPodMetrics {
132
+ name: string;
133
+ component?: DiagnosticsComponentName | "server";
134
+ cpuUsedCores?: number;
135
+ memoryUsedBytes?: number;
136
+ }
137
+
138
+ /**
139
+ * `usedBytes`/`availableBytes` exist only for a volume the server pod mounts (`mountedByServer`). `sharesNodeDisk` means the
140
+ * figures are the whole node filesystem's, and the volume's own size is not enforced.
141
+ */
142
+ export interface DiagnosticsPvcMetrics {
143
+ name: string;
144
+ phase: string;
145
+ storageClass?: string;
146
+ requestedBytes?: number;
147
+ capacityBytes?: number;
148
+ usedBytes?: number;
149
+ availableBytes?: number;
150
+ mountedByServer: boolean;
151
+ sharesNodeDisk?: boolean;
152
+ }
153
+
154
+ export interface DiagnosticsMetrics {
155
+ collectedAt: string;
156
+ process: {
157
+ cpuPercent: number;
158
+ rssBytes: number;
159
+ heapUsedBytes: number;
160
+ heapTotalBytes: number;
161
+ loadAverage: [number, number, number];
162
+ systemMemoryTotalBytes: number;
163
+ systemMemoryFreeBytes: number;
164
+ cpuCount: number;
165
+ };
166
+ host: DiagnosticsHostMetrics;
167
+ kubernetes: {
168
+ available: boolean;
169
+ reason?: string;
170
+ namespace?: {
171
+ name: string;
172
+ /** Whether the pod figures could be read: they need metrics-server. */
173
+ podMetricsAvailable: boolean;
174
+ podMetricsReason?: string;
175
+ cpuUsedCores?: number;
176
+ memoryUsedBytes?: number;
177
+ pods: DiagnosticsPodMetrics[];
178
+ };
179
+ pvcs: DiagnosticsPvcMetrics[];
180
+ errors: string[];
181
+ };
182
+ }
183
+
184
+ /** What is installed and running: the server process, its packages, and the other containers of the install. */
185
+ export function getDiagnosticsVersions(): Promise<DiagnosticsVersions> {
186
+ return apiFetch<DiagnosticsVersions>("/admin/diagnostics/versions");
187
+ }
188
+
189
+ /** The Kubernetes version, and the nodes the install's pods run on. */
190
+ export function getDiagnosticsRuntime(): Promise<DiagnosticsRuntime> {
191
+ return apiFetch<DiagnosticsRuntime>("/admin/diagnostics/runtime");
192
+ }
193
+
194
+ /** One sample of live resource use. Polled by the System tab. */
195
+ export function getDiagnosticsMetrics(): Promise<DiagnosticsMetrics> {
196
+ return apiFetch<DiagnosticsMetrics>("/admin/diagnostics/metrics");
197
+ }
@@ -0,0 +1,28 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** Saves `content` to the viewer's computer as a file called `filename`. What the download buttons call, so a test can swap it. */
7
+ export type SaveFile = (filename: string, content: string, mimeType: string) => void;
8
+
9
+ /**
10
+ * The browser way to save text it made itself: a Blob behind an object URL, opened by a temporary `<a download>`. The URL is
11
+ * revoked once the click has been handled (not in the same turn: some browsers start the download after it).
12
+ */
13
+ export const saveTextFile: SaveFile = (filename, content, mimeType) => {
14
+ const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
15
+ const link = document.createElement("a");
16
+ link.href = url;
17
+ link.download = filename;
18
+ link.style.display = "none";
19
+ document.body.appendChild(link);
20
+ link.click();
21
+ link.remove();
22
+ setTimeout(() => URL.revokeObjectURL(url), 0);
23
+ };
24
+
25
+ /** `rapidmx-server-logs-2026-09-26T10-30-00.000Z.log`: the name, then the time with the colons a file name cannot hold replaced. */
26
+ export function timestampedFilename(name: string, extension: string, now: Date = new Date()): string {
27
+ return `${name}-${now.toISOString().replace(/:/g, "-")}.${extension}`;
28
+ }
@@ -0,0 +1,106 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
6
+ import { isElevationRequired } from "../elevation.js";
7
+
8
+ /** What is shown where a number is missing or not a number. */
9
+ export const NO_VALUE = "—";
10
+
11
+ const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
12
+
13
+ const isNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
14
+
15
+ /** `1536` as `1.5 KiB` (binary units, as Kubernetes reports them). */
16
+ export function formatBytes(bytes: number | undefined): string {
17
+ if (!isNumber(bytes)) {
18
+ return NO_VALUE;
19
+ }
20
+ let value = Math.abs(bytes);
21
+ let unit = 0;
22
+ while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
23
+ value /= 1024;
24
+ unit += 1;
25
+ }
26
+ const text = unit === 0 || value >= 100 ? value.toFixed(0) : value.toFixed(1);
27
+ return `${bytes < 0 ? "-" : ""}${text} ${BYTE_UNITS[unit]}`;
28
+ }
29
+
30
+ /** CPU as cores: `0.25 cores`, `1 core`, `4 cores`. */
31
+ export function formatCores(cores: number | undefined): string {
32
+ if (!isNumber(cores)) {
33
+ return NO_VALUE;
34
+ }
35
+ const text = cores >= 10 ? cores.toFixed(0) : String(Number(cores.toFixed(2)));
36
+ return `${text} ${text === "1" ? "core" : "cores"}`;
37
+ }
38
+
39
+ /** `42.34` as `42.3%`. */
40
+ export function formatPercent(percent: number | undefined): string {
41
+ return isNumber(percent) ? `${percent.toFixed(1)}%` : NO_VALUE;
42
+ }
43
+
44
+ /** `used` as a percentage of `capacity`, or `undefined` when either is missing or the capacity is not positive. */
45
+ export function percentOf(used: number | undefined, capacity: number | undefined): number | undefined {
46
+ return isNumber(used) && isNumber(capacity) && capacity > 0 ? (used / capacity) * 100 : undefined;
47
+ }
48
+
49
+ /** Seconds as `3d 4h`, `4h 12m`, `12m 5s` or `5s`: the two largest units that are not zero. */
50
+ export function formatUptime(seconds: number | undefined): string {
51
+ if (!isNumber(seconds) || seconds < 0) {
52
+ return NO_VALUE;
53
+ }
54
+ const total = Math.floor(seconds);
55
+ const parts: [number, string][] = [
56
+ [Math.floor(total / 86400), "d"],
57
+ [Math.floor((total % 86400) / 3600), "h"],
58
+ [Math.floor((total % 3600) / 60), "m"],
59
+ [total % 60, "s"],
60
+ ];
61
+ const start = parts.findIndex(([value]) => value > 0);
62
+ if (start === -1) {
63
+ return "0s";
64
+ }
65
+ return parts
66
+ .slice(start, start + 2)
67
+ .map(([value, unit]) => `${value}${unit}`)
68
+ .join(" ");
69
+ }
70
+
71
+ /** A date as the viewer's local date and time, or a dash for a missing one (an unreadable one is shown as it came). */
72
+ export function formatDateTime(value: string | undefined): string {
73
+ if (!value) {
74
+ return NO_VALUE;
75
+ }
76
+ const date = new Date(value);
77
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
78
+ }
79
+
80
+ /** `sha256:0123456789abcdef...` as `0123456789ab`. */
81
+ export function shortDigest(digest: string | undefined): string {
82
+ return digest ? digest.replace(/^[a-z0-9]+:/i, "").slice(0, 12) : NO_VALUE;
83
+ }
84
+
85
+ /** Shown when the server wants the administrator to have confirmed their identity recently (`api-104`). */
86
+ export const ELEVATION_MESSAGE =
87
+ "Diagnostics needs you to have recently confirmed your identity. Reload this page, or sign in again, then try once more.";
88
+
89
+ /** Shown for a 403 that is not about elevation, or a 401. */
90
+ export const NOT_AUTHORISED_MESSAGE = "You are not authorised to view diagnostics.";
91
+
92
+ /** Whether the caller is not (or no longer) allowed: retrying the same request would fail the same way. */
93
+ export function isPermissionError(err: unknown): boolean {
94
+ return err instanceof ApiRequestError && (err.status === 403 || err.status === 401);
95
+ }
96
+
97
+ /** Why a request failed, in words for the administrator. */
98
+ export function describeError(err: unknown, fallback: string): string {
99
+ if (isElevationRequired(err)) {
100
+ return ELEVATION_MESSAGE;
101
+ }
102
+ if (isPermissionError(err)) {
103
+ return NOT_AUTHORISED_MESSAGE;
104
+ }
105
+ return err instanceof ApiRequestError ? err.message || fallback : fallback;
106
+ }
@@ -0,0 +1,231 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { apiUrl } from "@rapidmx/react-shared/util/api.js";
6
+ import { isControlFrame, LogEntry, parseLogFrame } from "./logLines.js";
7
+
8
+ /**
9
+ * A client for the server's live log stream: the WebSocket of the `/admin` route's `logs` endpoint (`<origin>/api/admin/logs`).
10
+ * It follows the shape of `@rapidmx/react-shared`'s `PushClient`, and differs where the protocol does.
11
+ *
12
+ * - It authenticates from the `jwt` cookie the browser sends with the upgrade. The server closes the socket with code 1002 when
13
+ * the caller may not read the logs, or the stream is not set up on the server, with an error code as the close reason. That is
14
+ * final: retrying would be refused the same way, so the client stops with an error and does not reconnect.
15
+ * - The first frame is a control message, `{"id":0,"type":"SUBSCRIBED","success":true,"data":"<channel>"}`, which says the stream
16
+ * is live. It is not a log line. Every later frame is the JSON of one Winston `info` object (see `parseLogFrame()`).
17
+ * - Delivery is fire-and-forget: lines logged while the socket was down are not replayed.
18
+ * - Any other close is retried with exponential backoff and jitter until `stop()`.
19
+ */
20
+
21
+ /** The subset of the browser's `WebSocket` this client uses - what a test's fake implements. */
22
+ export interface LogSocket {
23
+ readyState: number;
24
+ close(code?: number, reason?: string): void;
25
+ onopen: ((event: unknown) => void) | null;
26
+ onmessage: ((event: { data: unknown }) => void) | null;
27
+ onclose: ((event: { code?: number; reason?: string }) => void) | null;
28
+ onerror: ((event: unknown) => void) | null;
29
+ }
30
+
31
+ export type LogSocketFactory = (url: string) => LogSocket;
32
+
33
+ /**
34
+ * `connecting`: opening the first connection. `live`: subscribed. `reconnecting`: the connection was lost, or a reconnect is
35
+ * being tried. `closed`: stopped (or never started). `error`: refused for good, with `error()` saying why.
36
+ */
37
+ export type LogStreamStatus = "connecting" | "live" | "reconnecting" | "closed" | "error";
38
+
39
+ /** The close code the server uses when it refuses the stream (a permission or configuration failure). */
40
+ export const LOG_CLOSE_REFUSED = 1002;
41
+
42
+ /** The first reconnect waits about this long, and each further failure doubles it ... */
43
+ export const LOG_BACKOFF_BASE_MS = 1_000;
44
+ /** ... up to this. */
45
+ export const LOG_BACKOFF_MAX_MS = 30_000;
46
+
47
+ /** The log stream's URL: `apiUrl("/admin/logs")` made absolute (a relative one takes this page's origin) on `ws:`/`wss:` to
48
+ * match. `undefined` where there is no origin to use (server-side rendering). */
49
+ export function logsUrl(): string | undefined {
50
+ const url = apiUrl("/admin/logs");
51
+ let absolute: string | undefined = url;
52
+ if (!/^https?:\/\//i.test(url)) {
53
+ absolute = typeof window === "undefined" ? undefined : `${window.location.origin}${url}`;
54
+ }
55
+ return absolute?.replace(/^http/i, "ws");
56
+ }
57
+
58
+ function defaultSocketFactory(): LogSocketFactory | undefined {
59
+ return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url) as unknown as LogSocket;
60
+ }
61
+
62
+ /** What the error for a refused stream says: the server's reason is an error code, so it is shown as one. */
63
+ export function refusedMessage(reason: string | undefined): string {
64
+ return reason
65
+ ? `The server refused the log stream (${reason}). Check that you are an administrator and that log streaming is enabled.`
66
+ : "The server refused the log stream. Check that you are an administrator and that log streaming is enabled.";
67
+ }
68
+
69
+ export interface LogClientOptions {
70
+ /** The URL to connect to. Defaults to `logsUrl()`. */
71
+ url?: () => string | undefined;
72
+ /** Creates the socket. Defaults to the browser's `WebSocket`, if there is one. */
73
+ createSocket?: LogSocketFactory;
74
+ /** A `Math.random()` stand-in, for the backoff's jitter. */
75
+ random?: () => number;
76
+ /** A clock, for the time lines are received at. */
77
+ now?: () => number;
78
+ }
79
+
80
+ export class LogClient {
81
+ private socket: LogSocket | undefined;
82
+ private running = false;
83
+ private attempt = 0;
84
+ private timer: ReturnType<typeof setTimeout> | undefined;
85
+ private nextId = 1;
86
+ private current: LogStreamStatus = "closed";
87
+ private failure: string | undefined;
88
+ private readonly entryListeners = new Set<(entry: LogEntry) => void>();
89
+ private readonly statusListeners = new Set<(status: LogStreamStatus, error: string | undefined) => void>();
90
+
91
+ constructor(private readonly options: LogClientOptions = {}) {}
92
+
93
+ get status(): LogStreamStatus {
94
+ return this.current;
95
+ }
96
+
97
+ /** Why the stream is in the `error` state. */
98
+ get error(): string | undefined {
99
+ return this.failure;
100
+ }
101
+
102
+ /** Connects, and keeps reconnecting until `stop()`, or until the server refuses the stream. A no-op while it already runs. */
103
+ start(): void {
104
+ if (this.running) {
105
+ return;
106
+ }
107
+ this.running = true;
108
+ this.attempt = 0;
109
+ this.connect();
110
+ }
111
+
112
+ /** Closes the socket and stops reconnecting. The client can be started again. */
113
+ stop(): void {
114
+ this.running = false;
115
+ clearTimeout(this.timer);
116
+ this.timer = undefined;
117
+ this.detach();
118
+ this.setStatus("closed");
119
+ }
120
+
121
+ /** Calls `listener` with every log line; returns the function that stops it. */
122
+ onEntry(listener: (entry: LogEntry) => void): () => void {
123
+ this.entryListeners.add(listener);
124
+ return () => this.entryListeners.delete(listener);
125
+ }
126
+
127
+ /** Calls `listener` whenever the status changes; returns the function that stops it. */
128
+ onStatus(listener: (status: LogStreamStatus, error: string | undefined) => void): () => void {
129
+ this.statusListeners.add(listener);
130
+ return () => this.statusListeners.delete(listener);
131
+ }
132
+
133
+ private setStatus(status: LogStreamStatus, error?: string): void {
134
+ if (status === this.current && error === this.failure) {
135
+ return;
136
+ }
137
+ this.current = status;
138
+ this.failure = error;
139
+ for (const listener of [...this.statusListeners]) {
140
+ listener(status, error);
141
+ }
142
+ }
143
+
144
+ /** Lets go of the socket without hearing about it any more, and closes it. */
145
+ private detach(): void {
146
+ const socket = this.socket;
147
+ this.socket = undefined;
148
+ if (socket) {
149
+ socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
150
+ try {
151
+ socket.close(1000, "closing");
152
+ } catch {
153
+ // Already gone.
154
+ }
155
+ }
156
+ }
157
+
158
+ private connect(): void {
159
+ const url = (this.options.url ?? logsUrl)();
160
+ const createSocket = this.options.createSocket ?? defaultSocketFactory();
161
+ if (!url || !createSocket) {
162
+ this.running = false;
163
+ this.setStatus("error", "This browser cannot open a WebSocket to the server.");
164
+ return;
165
+ }
166
+ let socket: LogSocket;
167
+ try {
168
+ socket = createSocket(url);
169
+ } catch {
170
+ this.scheduleReconnect();
171
+ return;
172
+ }
173
+ this.socket = socket;
174
+ this.setStatus(this.attempt === 0 ? "connecting" : "reconnecting");
175
+ socket.onmessage = (event) => this.handleFrame(event.data);
176
+ socket.onclose = (event) => {
177
+ if (this.socket !== socket) {
178
+ return;
179
+ }
180
+ this.socket = undefined;
181
+ if (event.code === LOG_CLOSE_REFUSED) {
182
+ this.running = false;
183
+ this.setStatus("error", refusedMessage(event.reason));
184
+ return;
185
+ }
186
+ this.scheduleReconnect();
187
+ };
188
+ // A failed connection is followed by a close, which is what triggers the retry.
189
+ socket.onerror = () => undefined;
190
+ }
191
+
192
+ /** Waits out an exponentially growing, jittered delay ("equal jitter": half fixed, half random), then reconnects. */
193
+ private scheduleReconnect(): void {
194
+ const ceiling = Math.min(LOG_BACKOFF_MAX_MS, LOG_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 30));
195
+ const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
196
+ this.attempt += 1;
197
+ this.setStatus("reconnecting");
198
+ this.timer = setTimeout(() => {
199
+ this.timer = undefined;
200
+ this.connect();
201
+ }, delay);
202
+ }
203
+
204
+ private handleFrame(data: unknown): void {
205
+ if (typeof data !== "string") {
206
+ return;
207
+ }
208
+ const frame = parseLogFrame(data, this.nextId, (this.options.now ?? Date.now)());
209
+ if (isControlFrame(frame)) {
210
+ if (frame.success) {
211
+ this.attempt = 0;
212
+ this.setStatus("live");
213
+ } else {
214
+ // The server accepted the socket and refused the subscription: nothing will ever be sent.
215
+ this.running = false;
216
+ this.detach();
217
+ this.setStatus("error", refusedMessage(undefined));
218
+ }
219
+ return;
220
+ }
221
+ this.nextId += 1;
222
+ if (this.current !== "live") {
223
+ // A line before the greeting means the stream is flowing.
224
+ this.attempt = 0;
225
+ this.setStatus("live");
226
+ }
227
+ for (const listener of [...this.entryListeners]) {
228
+ listener(frame);
229
+ }
230
+ }
231
+ }