@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,9 @@
|
|
|
1
|
+
/** Saves `content` to the viewer's computer as a file called `filename`. What the download buttons call, so a test can swap it. */
|
|
2
|
+
export type SaveFile = (filename: string, content: string, mimeType: string) => void;
|
|
3
|
+
/**
|
|
4
|
+
* The browser way to save text it made itself: a Blob behind an object URL, opened by a temporary `<a download>`. The URL is
|
|
5
|
+
* revoked once the click has been handled (not in the same turn: some browsers start the download after it).
|
|
6
|
+
*/
|
|
7
|
+
export declare const saveTextFile: SaveFile;
|
|
8
|
+
/** `rapidmx-server-logs-2026-09-26T10-30-00.000Z.log`: the name, then the time with the colons a file name cannot hold replaced. */
|
|
9
|
+
export declare function timestampedFilename(name: string, extension: string, now?: Date): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The browser way to save text it made itself: a Blob behind an object URL, opened by a temporary `<a download>`. The URL is
|
|
7
|
+
* revoked once the click has been handled (not in the same turn: some browsers start the download after it).
|
|
8
|
+
*/
|
|
9
|
+
export const saveTextFile = (filename, content, mimeType) => {
|
|
10
|
+
const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
|
|
11
|
+
const link = document.createElement("a");
|
|
12
|
+
link.href = url;
|
|
13
|
+
link.download = filename;
|
|
14
|
+
link.style.display = "none";
|
|
15
|
+
document.body.appendChild(link);
|
|
16
|
+
link.click();
|
|
17
|
+
link.remove();
|
|
18
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
19
|
+
};
|
|
20
|
+
/** `rapidmx-server-logs-2026-09-26T10-30-00.000Z.log`: the name, then the time with the colons a file name cannot hold replaced. */
|
|
21
|
+
export function timestampedFilename(name, extension, now = new Date()) {
|
|
22
|
+
return `${name}-${now.toISOString().replace(/:/g, "-")}.${extension}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** What is shown where a number is missing or not a number. */
|
|
2
|
+
export declare const NO_VALUE = "\u2014";
|
|
3
|
+
/** `1536` as `1.5 KiB` (binary units, as Kubernetes reports them). */
|
|
4
|
+
export declare function formatBytes(bytes: number | undefined): string;
|
|
5
|
+
/** CPU as cores: `0.25 cores`, `1 core`, `4 cores`. */
|
|
6
|
+
export declare function formatCores(cores: number | undefined): string;
|
|
7
|
+
/** `42.34` as `42.3%`. */
|
|
8
|
+
export declare function formatPercent(percent: number | undefined): string;
|
|
9
|
+
/** `used` as a percentage of `capacity`, or `undefined` when either is missing or the capacity is not positive. */
|
|
10
|
+
export declare function percentOf(used: number | undefined, capacity: number | undefined): number | undefined;
|
|
11
|
+
/** Seconds as `3d 4h`, `4h 12m`, `12m 5s` or `5s`: the two largest units that are not zero. */
|
|
12
|
+
export declare function formatUptime(seconds: number | undefined): string;
|
|
13
|
+
/** 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). */
|
|
14
|
+
export declare function formatDateTime(value: string | undefined): string;
|
|
15
|
+
/** `sha256:0123456789abcdef...` as `0123456789ab`. */
|
|
16
|
+
export declare function shortDigest(digest: string | undefined): string;
|
|
17
|
+
/** Shown when the server wants the administrator to have confirmed their identity recently (`api-104`). */
|
|
18
|
+
export declare const ELEVATION_MESSAGE = "Diagnostics needs you to have recently confirmed your identity. Reload this page, or sign in again, then try once more.";
|
|
19
|
+
/** Shown for a 403 that is not about elevation, or a 401. */
|
|
20
|
+
export declare const NOT_AUTHORISED_MESSAGE = "You are not authorised to view diagnostics.";
|
|
21
|
+
/** Whether the caller is not (or no longer) allowed: retrying the same request would fail the same way. */
|
|
22
|
+
export declare function isPermissionError(err: unknown): boolean;
|
|
23
|
+
/** Why a request failed, in words for the administrator. */
|
|
24
|
+
export declare function describeError(err: unknown, fallback: string): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
/** What is shown where a number is missing or not a number. */
|
|
8
|
+
export const NO_VALUE = "—";
|
|
9
|
+
const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
10
|
+
const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
11
|
+
/** `1536` as `1.5 KiB` (binary units, as Kubernetes reports them). */
|
|
12
|
+
export function formatBytes(bytes) {
|
|
13
|
+
if (!isNumber(bytes)) {
|
|
14
|
+
return NO_VALUE;
|
|
15
|
+
}
|
|
16
|
+
let value = Math.abs(bytes);
|
|
17
|
+
let unit = 0;
|
|
18
|
+
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
|
|
19
|
+
value /= 1024;
|
|
20
|
+
unit += 1;
|
|
21
|
+
}
|
|
22
|
+
const text = unit === 0 || value >= 100 ? value.toFixed(0) : value.toFixed(1);
|
|
23
|
+
return `${bytes < 0 ? "-" : ""}${text} ${BYTE_UNITS[unit]}`;
|
|
24
|
+
}
|
|
25
|
+
/** CPU as cores: `0.25 cores`, `1 core`, `4 cores`. */
|
|
26
|
+
export function formatCores(cores) {
|
|
27
|
+
if (!isNumber(cores)) {
|
|
28
|
+
return NO_VALUE;
|
|
29
|
+
}
|
|
30
|
+
const text = cores >= 10 ? cores.toFixed(0) : String(Number(cores.toFixed(2)));
|
|
31
|
+
return `${text} ${text === "1" ? "core" : "cores"}`;
|
|
32
|
+
}
|
|
33
|
+
/** `42.34` as `42.3%`. */
|
|
34
|
+
export function formatPercent(percent) {
|
|
35
|
+
return isNumber(percent) ? `${percent.toFixed(1)}%` : NO_VALUE;
|
|
36
|
+
}
|
|
37
|
+
/** `used` as a percentage of `capacity`, or `undefined` when either is missing or the capacity is not positive. */
|
|
38
|
+
export function percentOf(used, capacity) {
|
|
39
|
+
return isNumber(used) && isNumber(capacity) && capacity > 0 ? (used / capacity) * 100 : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Seconds as `3d 4h`, `4h 12m`, `12m 5s` or `5s`: the two largest units that are not zero. */
|
|
42
|
+
export function formatUptime(seconds) {
|
|
43
|
+
if (!isNumber(seconds) || seconds < 0) {
|
|
44
|
+
return NO_VALUE;
|
|
45
|
+
}
|
|
46
|
+
const total = Math.floor(seconds);
|
|
47
|
+
const parts = [
|
|
48
|
+
[Math.floor(total / 86400), "d"],
|
|
49
|
+
[Math.floor((total % 86400) / 3600), "h"],
|
|
50
|
+
[Math.floor((total % 3600) / 60), "m"],
|
|
51
|
+
[total % 60, "s"],
|
|
52
|
+
];
|
|
53
|
+
const start = parts.findIndex(([value]) => value > 0);
|
|
54
|
+
if (start === -1) {
|
|
55
|
+
return "0s";
|
|
56
|
+
}
|
|
57
|
+
return parts
|
|
58
|
+
.slice(start, start + 2)
|
|
59
|
+
.map(([value, unit]) => `${value}${unit}`)
|
|
60
|
+
.join(" ");
|
|
61
|
+
}
|
|
62
|
+
/** 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). */
|
|
63
|
+
export function formatDateTime(value) {
|
|
64
|
+
if (!value) {
|
|
65
|
+
return NO_VALUE;
|
|
66
|
+
}
|
|
67
|
+
const date = new Date(value);
|
|
68
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
69
|
+
}
|
|
70
|
+
/** `sha256:0123456789abcdef...` as `0123456789ab`. */
|
|
71
|
+
export function shortDigest(digest) {
|
|
72
|
+
return digest ? digest.replace(/^[a-z0-9]+:/i, "").slice(0, 12) : NO_VALUE;
|
|
73
|
+
}
|
|
74
|
+
/** Shown when the server wants the administrator to have confirmed their identity recently (`api-104`). */
|
|
75
|
+
export const ELEVATION_MESSAGE = "Diagnostics needs you to have recently confirmed your identity. Reload this page, or sign in again, then try once more.";
|
|
76
|
+
/** Shown for a 403 that is not about elevation, or a 401. */
|
|
77
|
+
export const NOT_AUTHORISED_MESSAGE = "You are not authorised to view diagnostics.";
|
|
78
|
+
/** Whether the caller is not (or no longer) allowed: retrying the same request would fail the same way. */
|
|
79
|
+
export function isPermissionError(err) {
|
|
80
|
+
return err instanceof ApiRequestError && (err.status === 403 || err.status === 401);
|
|
81
|
+
}
|
|
82
|
+
/** Why a request failed, in words for the administrator. */
|
|
83
|
+
export function describeError(err, fallback) {
|
|
84
|
+
if (isElevationRequired(err)) {
|
|
85
|
+
return ELEVATION_MESSAGE;
|
|
86
|
+
}
|
|
87
|
+
if (isPermissionError(err)) {
|
|
88
|
+
return NOT_AUTHORISED_MESSAGE;
|
|
89
|
+
}
|
|
90
|
+
return err instanceof ApiRequestError ? err.message || fallback : fallback;
|
|
91
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { LogEntry } from "./logLines.js";
|
|
2
|
+
/**
|
|
3
|
+
* A client for the server's live log stream: the WebSocket of the `/admin` route's `logs` endpoint (`<origin>/api/admin/logs`).
|
|
4
|
+
* It follows the shape of `@rapidmx/react-shared`'s `PushClient`, and differs where the protocol does.
|
|
5
|
+
*
|
|
6
|
+
* - It authenticates from the `jwt` cookie the browser sends with the upgrade. The server closes the socket with code 1002 when
|
|
7
|
+
* 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
|
|
8
|
+
* final: retrying would be refused the same way, so the client stops with an error and does not reconnect.
|
|
9
|
+
* - The first frame is a control message, `{"id":0,"type":"SUBSCRIBED","success":true,"data":"<channel>"}`, which says the stream
|
|
10
|
+
* is live. It is not a log line. Every later frame is the JSON of one Winston `info` object (see `parseLogFrame()`).
|
|
11
|
+
* - Delivery is fire-and-forget: lines logged while the socket was down are not replayed.
|
|
12
|
+
* - Any other close is retried with exponential backoff and jitter until `stop()`.
|
|
13
|
+
*/
|
|
14
|
+
/** The subset of the browser's `WebSocket` this client uses - what a test's fake implements. */
|
|
15
|
+
export interface LogSocket {
|
|
16
|
+
readyState: number;
|
|
17
|
+
close(code?: number, reason?: string): void;
|
|
18
|
+
onopen: ((event: unknown) => void) | null;
|
|
19
|
+
onmessage: ((event: {
|
|
20
|
+
data: unknown;
|
|
21
|
+
}) => void) | null;
|
|
22
|
+
onclose: ((event: {
|
|
23
|
+
code?: number;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}) => void) | null;
|
|
26
|
+
onerror: ((event: unknown) => void) | null;
|
|
27
|
+
}
|
|
28
|
+
export type LogSocketFactory = (url: string) => LogSocket;
|
|
29
|
+
/**
|
|
30
|
+
* `connecting`: opening the first connection. `live`: subscribed. `reconnecting`: the connection was lost, or a reconnect is
|
|
31
|
+
* being tried. `closed`: stopped (or never started). `error`: refused for good, with `error()` saying why.
|
|
32
|
+
*/
|
|
33
|
+
export type LogStreamStatus = "connecting" | "live" | "reconnecting" | "closed" | "error";
|
|
34
|
+
/** The close code the server uses when it refuses the stream (a permission or configuration failure). */
|
|
35
|
+
export declare const LOG_CLOSE_REFUSED = 1002;
|
|
36
|
+
/** The first reconnect waits about this long, and each further failure doubles it ... */
|
|
37
|
+
export declare const LOG_BACKOFF_BASE_MS = 1000;
|
|
38
|
+
/** ... up to this. */
|
|
39
|
+
export declare const LOG_BACKOFF_MAX_MS = 30000;
|
|
40
|
+
/** The log stream's URL: `apiUrl("/admin/logs")` made absolute (a relative one takes this page's origin) on `ws:`/`wss:` to
|
|
41
|
+
* match. `undefined` where there is no origin to use (server-side rendering). */
|
|
42
|
+
export declare function logsUrl(): string | undefined;
|
|
43
|
+
/** What the error for a refused stream says: the server's reason is an error code, so it is shown as one. */
|
|
44
|
+
export declare function refusedMessage(reason: string | undefined): string;
|
|
45
|
+
export interface LogClientOptions {
|
|
46
|
+
/** The URL to connect to. Defaults to `logsUrl()`. */
|
|
47
|
+
url?: () => string | undefined;
|
|
48
|
+
/** Creates the socket. Defaults to the browser's `WebSocket`, if there is one. */
|
|
49
|
+
createSocket?: LogSocketFactory;
|
|
50
|
+
/** A `Math.random()` stand-in, for the backoff's jitter. */
|
|
51
|
+
random?: () => number;
|
|
52
|
+
/** A clock, for the time lines are received at. */
|
|
53
|
+
now?: () => number;
|
|
54
|
+
}
|
|
55
|
+
export declare class LogClient {
|
|
56
|
+
private readonly options;
|
|
57
|
+
private socket;
|
|
58
|
+
private running;
|
|
59
|
+
private attempt;
|
|
60
|
+
private timer;
|
|
61
|
+
private nextId;
|
|
62
|
+
private current;
|
|
63
|
+
private failure;
|
|
64
|
+
private readonly entryListeners;
|
|
65
|
+
private readonly statusListeners;
|
|
66
|
+
constructor(options?: LogClientOptions);
|
|
67
|
+
get status(): LogStreamStatus;
|
|
68
|
+
/** Why the stream is in the `error` state. */
|
|
69
|
+
get error(): string | undefined;
|
|
70
|
+
/** Connects, and keeps reconnecting until `stop()`, or until the server refuses the stream. A no-op while it already runs. */
|
|
71
|
+
start(): void;
|
|
72
|
+
/** Closes the socket and stops reconnecting. The client can be started again. */
|
|
73
|
+
stop(): void;
|
|
74
|
+
/** Calls `listener` with every log line; returns the function that stops it. */
|
|
75
|
+
onEntry(listener: (entry: LogEntry) => void): () => void;
|
|
76
|
+
/** Calls `listener` whenever the status changes; returns the function that stops it. */
|
|
77
|
+
onStatus(listener: (status: LogStreamStatus, error: string | undefined) => void): () => void;
|
|
78
|
+
private setStatus;
|
|
79
|
+
/** Lets go of the socket without hearing about it any more, and closes it. */
|
|
80
|
+
private detach;
|
|
81
|
+
private connect;
|
|
82
|
+
/** Waits out an exponentially growing, jittered delay ("equal jitter": half fixed, half random), then reconnects. */
|
|
83
|
+
private scheduleReconnect;
|
|
84
|
+
private handleFrame;
|
|
85
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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, parseLogFrame } from "./logLines.js";
|
|
7
|
+
/** The close code the server uses when it refuses the stream (a permission or configuration failure). */
|
|
8
|
+
export const LOG_CLOSE_REFUSED = 1002;
|
|
9
|
+
/** The first reconnect waits about this long, and each further failure doubles it ... */
|
|
10
|
+
export const LOG_BACKOFF_BASE_MS = 1000;
|
|
11
|
+
/** ... up to this. */
|
|
12
|
+
export const LOG_BACKOFF_MAX_MS = 30000;
|
|
13
|
+
/** The log stream's URL: `apiUrl("/admin/logs")` made absolute (a relative one takes this page's origin) on `ws:`/`wss:` to
|
|
14
|
+
* match. `undefined` where there is no origin to use (server-side rendering). */
|
|
15
|
+
export function logsUrl() {
|
|
16
|
+
const url = apiUrl("/admin/logs");
|
|
17
|
+
let absolute = url;
|
|
18
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
19
|
+
absolute = typeof window === "undefined" ? undefined : `${window.location.origin}${url}`;
|
|
20
|
+
}
|
|
21
|
+
return absolute?.replace(/^http/i, "ws");
|
|
22
|
+
}
|
|
23
|
+
function defaultSocketFactory() {
|
|
24
|
+
return typeof WebSocket === "undefined" ? undefined : (url) => new WebSocket(url);
|
|
25
|
+
}
|
|
26
|
+
/** What the error for a refused stream says: the server's reason is an error code, so it is shown as one. */
|
|
27
|
+
export function refusedMessage(reason) {
|
|
28
|
+
return reason
|
|
29
|
+
? `The server refused the log stream (${reason}). Check that you are an administrator and that log streaming is enabled.`
|
|
30
|
+
: "The server refused the log stream. Check that you are an administrator and that log streaming is enabled.";
|
|
31
|
+
}
|
|
32
|
+
export class LogClient {
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
this.running = false;
|
|
36
|
+
this.attempt = 0;
|
|
37
|
+
this.nextId = 1;
|
|
38
|
+
this.current = "closed";
|
|
39
|
+
this.entryListeners = new Set();
|
|
40
|
+
this.statusListeners = new Set();
|
|
41
|
+
}
|
|
42
|
+
get status() {
|
|
43
|
+
return this.current;
|
|
44
|
+
}
|
|
45
|
+
/** Why the stream is in the `error` state. */
|
|
46
|
+
get error() {
|
|
47
|
+
return this.failure;
|
|
48
|
+
}
|
|
49
|
+
/** Connects, and keeps reconnecting until `stop()`, or until the server refuses the stream. A no-op while it already runs. */
|
|
50
|
+
start() {
|
|
51
|
+
if (this.running) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.running = true;
|
|
55
|
+
this.attempt = 0;
|
|
56
|
+
this.connect();
|
|
57
|
+
}
|
|
58
|
+
/** Closes the socket and stops reconnecting. The client can be started again. */
|
|
59
|
+
stop() {
|
|
60
|
+
this.running = false;
|
|
61
|
+
clearTimeout(this.timer);
|
|
62
|
+
this.timer = undefined;
|
|
63
|
+
this.detach();
|
|
64
|
+
this.setStatus("closed");
|
|
65
|
+
}
|
|
66
|
+
/** Calls `listener` with every log line; returns the function that stops it. */
|
|
67
|
+
onEntry(listener) {
|
|
68
|
+
this.entryListeners.add(listener);
|
|
69
|
+
return () => this.entryListeners.delete(listener);
|
|
70
|
+
}
|
|
71
|
+
/** Calls `listener` whenever the status changes; returns the function that stops it. */
|
|
72
|
+
onStatus(listener) {
|
|
73
|
+
this.statusListeners.add(listener);
|
|
74
|
+
return () => this.statusListeners.delete(listener);
|
|
75
|
+
}
|
|
76
|
+
setStatus(status, error) {
|
|
77
|
+
if (status === this.current && error === this.failure) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.current = status;
|
|
81
|
+
this.failure = error;
|
|
82
|
+
for (const listener of [...this.statusListeners]) {
|
|
83
|
+
listener(status, error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Lets go of the socket without hearing about it any more, and closes it. */
|
|
87
|
+
detach() {
|
|
88
|
+
const socket = this.socket;
|
|
89
|
+
this.socket = undefined;
|
|
90
|
+
if (socket) {
|
|
91
|
+
socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null;
|
|
92
|
+
try {
|
|
93
|
+
socket.close(1000, "closing");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Already gone.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
connect() {
|
|
101
|
+
const url = (this.options.url ?? logsUrl)();
|
|
102
|
+
const createSocket = this.options.createSocket ?? defaultSocketFactory();
|
|
103
|
+
if (!url || !createSocket) {
|
|
104
|
+
this.running = false;
|
|
105
|
+
this.setStatus("error", "This browser cannot open a WebSocket to the server.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let socket;
|
|
109
|
+
try {
|
|
110
|
+
socket = createSocket(url);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
this.scheduleReconnect();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.socket = socket;
|
|
117
|
+
this.setStatus(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
118
|
+
socket.onmessage = (event) => this.handleFrame(event.data);
|
|
119
|
+
socket.onclose = (event) => {
|
|
120
|
+
if (this.socket !== socket) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.socket = undefined;
|
|
124
|
+
if (event.code === LOG_CLOSE_REFUSED) {
|
|
125
|
+
this.running = false;
|
|
126
|
+
this.setStatus("error", refusedMessage(event.reason));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.scheduleReconnect();
|
|
130
|
+
};
|
|
131
|
+
// A failed connection is followed by a close, which is what triggers the retry.
|
|
132
|
+
socket.onerror = () => undefined;
|
|
133
|
+
}
|
|
134
|
+
/** Waits out an exponentially growing, jittered delay ("equal jitter": half fixed, half random), then reconnects. */
|
|
135
|
+
scheduleReconnect() {
|
|
136
|
+
const ceiling = Math.min(LOG_BACKOFF_MAX_MS, LOG_BACKOFF_BASE_MS * 2 ** Math.min(this.attempt, 30));
|
|
137
|
+
const delay = ceiling / 2 + ((this.options.random ?? Math.random)() * ceiling) / 2;
|
|
138
|
+
this.attempt += 1;
|
|
139
|
+
this.setStatus("reconnecting");
|
|
140
|
+
this.timer = setTimeout(() => {
|
|
141
|
+
this.timer = undefined;
|
|
142
|
+
this.connect();
|
|
143
|
+
}, delay);
|
|
144
|
+
}
|
|
145
|
+
handleFrame(data) {
|
|
146
|
+
if (typeof data !== "string") {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const frame = parseLogFrame(data, this.nextId, (this.options.now ?? Date.now)());
|
|
150
|
+
if (isControlFrame(frame)) {
|
|
151
|
+
if (frame.success) {
|
|
152
|
+
this.attempt = 0;
|
|
153
|
+
this.setStatus("live");
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// The server accepted the socket and refused the subscription: nothing will ever be sent.
|
|
157
|
+
this.running = false;
|
|
158
|
+
this.detach();
|
|
159
|
+
this.setStatus("error", refusedMessage(undefined));
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.nextId += 1;
|
|
164
|
+
if (this.current !== "live") {
|
|
165
|
+
// A line before the greeting means the stream is flowing.
|
|
166
|
+
this.attempt = 0;
|
|
167
|
+
this.setStatus("live");
|
|
168
|
+
}
|
|
169
|
+
for (const listener of [...this.entryListeners]) {
|
|
170
|
+
listener(frame);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The levels of the server's logger (Winston's npm levels), most severe first. */
|
|
2
|
+
export declare const LOG_LEVELS: readonly ["error", "warn", "info", "http", "verbose", "debug", "silly"];
|
|
3
|
+
export type LogLevel = (typeof LOG_LEVELS)[number];
|
|
4
|
+
/** One line of the server's log stream. */
|
|
5
|
+
export interface LogEntry {
|
|
6
|
+
/** Counts up from 1 in the order lines arrived; unique for the life of the stream. */
|
|
7
|
+
id: number;
|
|
8
|
+
/** When this page received it (`Date.now()`). */
|
|
9
|
+
receivedAt: number;
|
|
10
|
+
/** The level as the server wrote it (lower-cased, without colour codes); empty when it wrote none. */
|
|
11
|
+
level: string;
|
|
12
|
+
message: string;
|
|
13
|
+
/** As the server wrote it, when it did. */
|
|
14
|
+
timestamp?: string;
|
|
15
|
+
/** Every other field the server logged with the line (`label`, `service`, `stack`, ...). */
|
|
16
|
+
extra: Record<string, unknown>;
|
|
17
|
+
/** The frame as one line of JSON: what the `.ndjson` download holds. */
|
|
18
|
+
json: string;
|
|
19
|
+
/** Whether the frame was JSON. A frame that was not is shown as it came, with no level. */
|
|
20
|
+
structured: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** What a frame is, when it is the server's own control message and not a log line. */
|
|
23
|
+
export interface ControlFrame {
|
|
24
|
+
control: true;
|
|
25
|
+
success: boolean;
|
|
26
|
+
data: unknown;
|
|
27
|
+
}
|
|
28
|
+
export declare const stripAnsi: (text: string) => string;
|
|
29
|
+
/**
|
|
30
|
+
* Reads one frame of the log stream. It is the control message (`{"id":0,"type":"SUBSCRIBED","success":true,...}`), a Winston
|
|
31
|
+
* `info` object (the JSON of `level`, `message` and whatever else was logged with them), or - since the server's logger can write
|
|
32
|
+
* anything - text that is not JSON at all. Whatever it is, the frame is never refused: a missing field is left empty.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseLogFrame(raw: string, id: number, receivedAt: number): LogEntry | ControlFrame;
|
|
35
|
+
export declare const isControlFrame: (frame: LogEntry | ControlFrame) => frame is ControlFrame;
|
|
36
|
+
/** When a line happened: what the server stamped it with, else when this page received it. */
|
|
37
|
+
export declare function entryTime(entry: LogEntry): string;
|
|
38
|
+
/** `<timestamp> <LEVEL> <message>`: the line of a `.log` download. A line with no level has none written. */
|
|
39
|
+
export declare function formatLogLine(entry: LogEntry): string;
|
|
40
|
+
/** The text of a `.log` download: one formatted line for each entry. */
|
|
41
|
+
export declare function entriesToLog(entries: LogEntry[]): string;
|
|
42
|
+
/** The text of an `.ndjson` download: each frame as one line of JSON. */
|
|
43
|
+
export declare function entriesToNdjson(entries: LogEntry[]): string;
|
|
44
|
+
/** Whether `entry` is one of `levels`. A line with no known level (text that was not JSON, an unusual level) is always kept. */
|
|
45
|
+
export declare function levelVisible(entry: LogEntry, levels: ReadonlySet<string>): boolean;
|
|
46
|
+
/** Whether `entry` contains `needle` (already lower-cased) in its time, level, message or other fields. */
|
|
47
|
+
export declare function entryMatches(entry: LogEntry, needle: string): boolean;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** The levels of the server's logger (Winston's npm levels), most severe first. */
|
|
6
|
+
export const LOG_LEVELS = ["error", "warn", "info", "http", "verbose", "debug", "silly"];
|
|
7
|
+
/** Colour codes a logger writes around a level (`\u001b[32minfo\u001b[39m`). */
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
10
|
+
export const stripAnsi = (text) => text.replace(ANSI_PATTERN, "");
|
|
11
|
+
/** A field's value as text: a string as it is, nothing as empty, anything else as JSON. */
|
|
12
|
+
function textOf(value) {
|
|
13
|
+
if (typeof value === "string") {
|
|
14
|
+
return stripAnsi(value);
|
|
15
|
+
}
|
|
16
|
+
return value === undefined ? "" : JSON.stringify(value);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Reads one frame of the log stream. It is the control message (`{"id":0,"type":"SUBSCRIBED","success":true,...}`), a Winston
|
|
20
|
+
* `info` object (the JSON of `level`, `message` and whatever else was logged with them), or - since the server's logger can write
|
|
21
|
+
* anything - text that is not JSON at all. Whatever it is, the frame is never refused: a missing field is left empty.
|
|
22
|
+
*/
|
|
23
|
+
export function parseLogFrame(raw, id, receivedAt) {
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
parsed = undefined;
|
|
30
|
+
}
|
|
31
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
receivedAt,
|
|
35
|
+
level: "",
|
|
36
|
+
message: stripAnsi(raw),
|
|
37
|
+
extra: {},
|
|
38
|
+
json: JSON.stringify({ raw }),
|
|
39
|
+
structured: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const frame = parsed;
|
|
43
|
+
if (frame.type === "SUBSCRIBED" && frame.level === undefined) {
|
|
44
|
+
return { control: true, success: frame.success !== false, data: frame.data };
|
|
45
|
+
}
|
|
46
|
+
const { level, message, timestamp, ...extra } = frame;
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
receivedAt,
|
|
50
|
+
level: textOf(level).toLowerCase(),
|
|
51
|
+
message: textOf(message),
|
|
52
|
+
timestamp: typeof timestamp === "string" ? timestamp : undefined,
|
|
53
|
+
extra,
|
|
54
|
+
json: JSON.stringify(frame),
|
|
55
|
+
structured: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export const isControlFrame = (frame) => "control" in frame;
|
|
59
|
+
/** When a line happened: what the server stamped it with, else when this page received it. */
|
|
60
|
+
export function entryTime(entry) {
|
|
61
|
+
return entry.timestamp ?? new Date(entry.receivedAt).toISOString();
|
|
62
|
+
}
|
|
63
|
+
/** `<timestamp> <LEVEL> <message>`: the line of a `.log` download. A line with no level has none written. */
|
|
64
|
+
export function formatLogLine(entry) {
|
|
65
|
+
return [entryTime(entry), entry.level.toUpperCase(), entry.message].filter((part) => part !== "").join(" ");
|
|
66
|
+
}
|
|
67
|
+
/** The text of a `.log` download: one formatted line for each entry. */
|
|
68
|
+
export function entriesToLog(entries) {
|
|
69
|
+
return entries.map((entry) => formatLogLine(entry).replace(/\r?\n/g, " ↵ ")).join("\n") + "\n";
|
|
70
|
+
}
|
|
71
|
+
/** The text of an `.ndjson` download: each frame as one line of JSON. */
|
|
72
|
+
export function entriesToNdjson(entries) {
|
|
73
|
+
return entries.map((entry) => entry.json).join("\n") + "\n";
|
|
74
|
+
}
|
|
75
|
+
/** Whether `entry` is one of `levels`. A line with no known level (text that was not JSON, an unusual level) is always kept. */
|
|
76
|
+
export function levelVisible(entry, levels) {
|
|
77
|
+
return !LOG_LEVELS.includes(entry.level) || levels.has(entry.level);
|
|
78
|
+
}
|
|
79
|
+
/** Whether `entry` contains `needle` (already lower-cased) in its time, level, message or other fields. */
|
|
80
|
+
export function entryMatches(entry, needle) {
|
|
81
|
+
if (needle === "") {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const extra = Object.keys(entry.extra).length > 0 ? JSON.stringify(entry.extra) : "";
|
|
85
|
+
return `${formatLogLine(entry)} ${extra}`.toLowerCase().includes(needle);
|
|
86
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
|
|
2
|
+
/** How often the System tab asks the server for a new sample. */
|
|
3
|
+
export declare const METRICS_POLL_INTERVAL_MS = 5000;
|
|
4
|
+
/** How many samples the System tab keeps: the last five minutes at `METRICS_POLL_INTERVAL_MS`. */
|
|
5
|
+
export declare const METRICS_HISTORY_SIZE = 60;
|
|
6
|
+
/** `history` with `sample` added, the oldest samples dropped beyond `METRICS_HISTORY_SIZE`. */
|
|
7
|
+
export declare function appendSample(history: DiagnosticsMetrics[], sample: DiagnosticsMetrics): DiagnosticsMetrics[];
|
|
8
|
+
/** The values `pick` finds in each sample, in order. A sample it finds nothing in (a node with no stats yet, say) is skipped. */
|
|
9
|
+
export declare function seriesOf(history: DiagnosticsMetrics[], pick: (sample: DiagnosticsMetrics) => number | undefined): number[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** How often the System tab asks the server for a new sample. */
|
|
2
|
+
export const METRICS_POLL_INTERVAL_MS = 5000;
|
|
3
|
+
/** How many samples the System tab keeps: the last five minutes at `METRICS_POLL_INTERVAL_MS`. */
|
|
4
|
+
export const METRICS_HISTORY_SIZE = 60;
|
|
5
|
+
/** `history` with `sample` added, the oldest samples dropped beyond `METRICS_HISTORY_SIZE`. */
|
|
6
|
+
export function appendSample(history, sample) {
|
|
7
|
+
return [...history, sample].slice(-METRICS_HISTORY_SIZE);
|
|
8
|
+
}
|
|
9
|
+
/** The values `pick` finds in each sample, in order. A sample it finds nothing in (a node with no stats yet, say) is skipped. */
|
|
10
|
+
export function seriesOf(history, pick) {
|
|
11
|
+
const values = [];
|
|
12
|
+
for (const sample of history) {
|
|
13
|
+
const value = pick(sample);
|
|
14
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
15
|
+
values.push(value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return values;
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface DiagnosticsResource<T> {
|
|
2
|
+
/** What was last loaded. It stays while a reload is under way, and after a reload that failed. */
|
|
3
|
+
data: T | undefined;
|
|
4
|
+
/** Why the last load failed. */
|
|
5
|
+
error: string | undefined;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Loads `load()` when the component mounts and again whenever `reloadKey` changes (the Refresh button). It is never polled.
|
|
10
|
+
* The answer to a load that a newer one (or an unmount) has overtaken is dropped.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useDiagnosticsResource<T>(load: () => Promise<T>, reloadKey: number, failure: string): DiagnosticsResource<T>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { describeError } from "./format.js";
|
|
7
|
+
/**
|
|
8
|
+
* Loads `load()` when the component mounts and again whenever `reloadKey` changes (the Refresh button). It is never polled.
|
|
9
|
+
* The answer to a load that a newer one (or an unmount) has overtaken is dropped.
|
|
10
|
+
*/
|
|
11
|
+
export function useDiagnosticsResource(load, reloadKey, failure) {
|
|
12
|
+
const [state, setState] = useState({ data: undefined, error: undefined, loading: true });
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
setState((previous) => ({ ...previous, error: undefined, loading: true }));
|
|
16
|
+
load().then((data) => {
|
|
17
|
+
if (!cancelled) {
|
|
18
|
+
setState({ data, error: undefined, loading: false });
|
|
19
|
+
}
|
|
20
|
+
}, (err) => {
|
|
21
|
+
if (!cancelled) {
|
|
22
|
+
setState((previous) => ({ ...previous, error: describeError(err, failure), loading: false }));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, [reloadKey]);
|
|
29
|
+
return state;
|
|
30
|
+
}
|