@rapidmx/web-client 0.19.0 → 0.21.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 (32) hide show
  1. package/README.md +1 -1
  2. package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
  3. package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
  4. package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
  6. package/apps/shared/components/admin/layout/AdminShell.tsx +2 -2
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +508 -35
  8. package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
  9. package/apps/shared/components/escrow/layout/EscrowShell.tsx +2 -2
  10. package/apps/shared/components/layout/AppShell.tsx +4 -2
  11. package/apps/shared/components/mail/ConversationList.tsx +18 -9
  12. package/apps/shared/components/mail/compose/ComposeWindow.tsx +24 -0
  13. package/apps/shared/components/mail/listPreferences.ts +3 -3
  14. package/apps/www/index.tsx +62 -12
  15. package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
  16. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
  17. package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
  18. package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
  19. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
  20. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  21. package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
  22. package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
  23. package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
  24. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
  25. package/dist/apps/shared/components/layout/AppShell.js +4 -2
  26. package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
  27. package/dist/apps/shared/components/mail/ConversationList.js +9 -5
  28. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
  29. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
  30. package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
  31. package/dist/apps/www/index.js +51 -10
  32. package/package.json +2 -2
package/README.md CHANGED
@@ -357,7 +357,7 @@ Plugin pages import these directly from `@rapidmx/react-shared`, which the serve
357
357
  use:
358
358
 
359
359
  - `branding/useBranding.js`: `useBranding()`, for the branding and icon of pages outside a shell;
360
- - `auth/session.js`: `useRedirectIfUnauthenticated()`, already called by every shell;
360
+ - `auth/session.js`: `useSessionRefresh()`, already called by every shell, which renews the sign-in before its hour is up;
361
361
  - `util/api.js`: `apiFetch()` and `ApiRequestError`, for calling the plugin's own API routes;
362
362
  - `mail/mailApi.js`: mailboxes and folders, such as `listMailboxes()` and `listFolders()`;
363
363
  - `components/buttons/Button.js`, `components/feedback/Alert.js` and `components/feedback/Skeleton.js`;
@@ -7,6 +7,7 @@ import type { DiagnosticsDiskMetrics, DiagnosticsMetrics } from "./diagnosticsAp
7
7
  import { formatBytes, formatCores, formatPercent, NO_VALUE, percentOf } from "./format.js";
8
8
  import { seriesOf } from "./metricsHistory.js";
9
9
  import MetricTile from "./MetricTile.js";
10
+ import PressureTiles from "./PressureTiles.js";
10
11
  import UsageMeter from "./UsageMeter.js";
11
12
 
12
13
  const percentLabel = (value: number) => formatPercent(value);
@@ -91,6 +92,7 @@ export default function HostCard({ host, history }: HostCardProps) {
91
92
  />
92
93
  <MetricTile label="Load average" value={formatLoad(host.loadAverage)} detail="1, 5 and 15 minutes" />
93
94
  </div>
95
+ <PressureTiles host={host} history={history} />
94
96
  <h3 className="mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted">Disks</h3>
95
97
  {disks.length === 0 ? (
96
98
  <p className="text-sm text-text-muted">No disks were reported.</p>
@@ -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 { DiagnosticsMetrics, DiagnosticsPressure } from "./diagnosticsApi.js";
7
+ import { formatBytes, formatPercent, percentOf } from "./format.js";
8
+ import { seriesOf } from "./metricsHistory.js";
9
+ import MetricTile from "./MetricTile.js";
10
+
11
+ /** From this share of the last 10 seconds spent stalled, pressure is called elevated ... */
12
+ export const ELEVATED_PRESSURE_PERCENT = 10;
13
+
14
+ /** ... and from this, high. Kernel documentation treats a few percent as ordinary and tens of percent as a machine that is struggling. */
15
+ export const HIGH_PRESSURE_PERCENT = 30;
16
+
17
+ export type PressureLevel = "Low" | "Elevated" | "High";
18
+
19
+ /** Pressure in words, so that it is never only a number or a colour. */
20
+ export function pressureLevel(percent: number): PressureLevel {
21
+ if (percent >= HIGH_PRESSURE_PERCENT) {
22
+ return "High";
23
+ }
24
+ return percent >= ELEVATED_PRESSURE_PERCENT ? "Elevated" : "Low";
25
+ }
26
+
27
+ type Host = NonNullable<DiagnosticsMetrics["host"]>;
28
+ type Resource = keyof NonNullable<Host["pressure"]>;
29
+
30
+ const RESOURCES: { key: Resource; label: string; waiting: string }[] = [
31
+ { key: "memory", label: "Memory pressure", waiting: "waiting for memory" },
32
+ { key: "io", label: "I/O pressure", waiting: "waiting for the disk" },
33
+ { key: "cpu", label: "CPU pressure", waiting: "waiting for a CPU" },
34
+ ];
35
+
36
+ function PressureTile({ label, waiting, stall, history, resource }: { label: string; waiting: string; stall: DiagnosticsPressure; history: DiagnosticsMetrics[]; resource: Resource }) {
37
+ const level = pressureLevel(stall.some.avg10);
38
+ return (
39
+ <MetricTile
40
+ label={label}
41
+ value={formatPercent(stall.some.avg10)}
42
+ detail={`${level}: tasks ${waiting}, last 10 seconds`}
43
+ history={{
44
+ values: seriesOf(history, (sample) => sample.host?.pressure?.[resource]?.some.avg10),
45
+ max: 100,
46
+ format: formatPercent,
47
+ }}
48
+ >
49
+ <div className="mt-2 text-xs text-text-muted">
50
+ Last minute {formatPercent(stall.some.avg60)}, last 5 minutes {formatPercent(stall.some.avg300)}
51
+ {stall.full && ` · every task waiting: ${formatPercent(stall.full.avg10)}`}
52
+ </div>
53
+ </MetricTile>
54
+ );
55
+ }
56
+
57
+ export interface PressureTilesProps {
58
+ host: Host;
59
+ history: DiagnosticsMetrics[];
60
+ }
61
+
62
+ /**
63
+ * How stalled the node is (Linux pressure stall information: the share of recent time tasks spent waiting for memory, the disk or
64
+ * a CPU), and how much memory the hypervisor holds in the machine's balloon. Both explain a node that is slow while nothing on it
65
+ * uses much: a guest counts ballooned memory as used though no process holds it, and a machine short of memory stalls on the disk.
66
+ * Nothing is shown where the kernel gives no such figures.
67
+ */
68
+ export default function PressureTiles({ host, history }: PressureTilesProps) {
69
+ const tiles = RESOURCES.filter(({ key }) => host.pressure?.[key]);
70
+ const balloon = host.balloon;
71
+ if (tiles.length === 0 && !balloon) {
72
+ return null;
73
+ }
74
+ const heldPercent = percentOf(balloon?.heldBytes, host.memoryTotalBytes);
75
+ return (
76
+ <>
77
+ <h3 className="mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted">Pressure</h3>
78
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
79
+ {RESOURCES.map(({ key, label, waiting }) => {
80
+ const stall = host.pressure?.[key];
81
+ return stall && <PressureTile key={key} label={label} waiting={waiting} stall={stall} history={history} resource={key} />;
82
+ })}
83
+ {balloon && (
84
+ <MetricTile
85
+ label="Held by the hypervisor"
86
+ value={formatBytes(balloon.heldBytes)}
87
+ detail={`of ${formatBytes(host.memoryTotalBytes)}`}
88
+ usage={
89
+ heldPercent === undefined
90
+ ? undefined
91
+ : { percent: heldPercent, detail: `${formatBytes(balloon.heldBytes)} in the memory balloon`, flag: false }
92
+ }
93
+ history={{
94
+ values: seriesOf(history, (sample) => sample.host?.balloon?.heldBytes),
95
+ max: host.memoryTotalBytes,
96
+ format: formatBytes,
97
+ }}
98
+ >
99
+ <div className="mt-2 text-xs text-text-muted">
100
+ Memory the hypervisor has taken back through the balloon. It counts as used above though no process holds it.{" "}
101
+ {formatBytes(balloon.inflatedTotalBytes)} taken since the node booted.
102
+ </div>
103
+ </MetricTile>
104
+ )}
105
+ </div>
106
+ </>
107
+ );
108
+ }
@@ -7,26 +7,40 @@ import type { DiagnosticsPvcMetrics } from "./diagnosticsApi.js";
7
7
  import { formatBytes, NO_VALUE, percentOf } from "./format.js";
8
8
  import UsageMeter from "./UsageMeter.js";
9
9
 
10
- const HEADINGS = ["Volume", "Phase", "Storage class", "Size", "Usage"];
10
+ const HEADINGS = ["Volume", "Phase", "Storage class", "Allocated", "Usage"];
11
11
 
12
12
  function Usage({ pvc }: { pvc: DiagnosticsPvcMetrics }) {
13
13
  if (!pvc.mountedByServer) {
14
14
  return <span className="text-text-muted">Usage not available: not mounted in the server pod.</span>;
15
15
  }
16
- const percent = percentOf(pvc.usedBytes, pvc.capacityBytes);
16
+ // On the node's disk the filesystem's figures are the disk's, so only a measured directory says what the volume holds (an
17
+ // older server sent the disk's figures without saying so).
18
+ const own = pvc.sharesNodeDisk && pvc.measuredBy !== "directory" ? undefined : pvc.usedBytes;
19
+ const percent = percentOf(own, pvc.capacityBytes);
17
20
  if (percent === undefined) {
18
- return <span className="text-text-muted">The server has no usage figures for this volume.</span>;
21
+ return (
22
+ <span className="text-text-muted">
23
+ {pvc.sharesNodeDisk
24
+ ? "Shares the node\u2019s disk, and what the volume holds could not be measured."
25
+ : "The server has no usage figures for this volume."}
26
+ </span>
27
+ );
19
28
  }
20
29
  return (
21
30
  <div>
22
31
  <UsageMeter
23
32
  label={`Usage of volume ${pvc.name}`}
24
33
  percent={percent}
25
- detail={`${formatBytes(pvc.usedBytes)} of ${formatBytes(pvc.capacityBytes)}`}
26
- flag={!pvc.sharesNodeDisk}
34
+ detail={`${pvc.usedPartial ? "At least " : ""}${formatBytes(own)} of ${formatBytes(pvc.capacityBytes)} allocated`}
27
35
  />
36
+ {pvc.usedPartial && (
37
+ <p className="mt-1 text-xs text-text-muted">The volume is too large to measure completely, so this is a lower bound.</p>
38
+ )}
28
39
  {pvc.sharesNodeDisk && (
29
- <p className="mt-1 text-xs text-text-muted">Shares the node&rsquo;s disk; the volume&rsquo;s size is not enforced.</p>
40
+ <p className="mt-1 text-xs text-text-muted">
41
+ Measured from the volume&rsquo;s directory. It shares the node&rsquo;s disk, which has {formatBytes(pvc.availableBytes)} free; the
42
+ volume&rsquo;s allocation is not enforced.
43
+ </p>
30
44
  )}
31
45
  </div>
32
46
  );
@@ -124,9 +124,22 @@ export interface DiagnosticsHostMetrics {
124
124
  memoryTotalBytes: number;
125
125
  memoryUsedBytes: number;
126
126
  loadAverage: [number, number, number];
127
+ /**
128
+ * Memory the hypervisor holds in the machine's memory balloon. It counts in `memoryUsedBytes` (a guest sees ballooned memory
129
+ * as used) though no process holds it. Absent where no balloon has ever taken any.
130
+ */
131
+ balloon?: { heldBytes: number; inflatedTotalBytes: number };
132
+ /** How stalled the node is waiting for memory, disk and CPU (Linux pressure stall information). Absent without kernel support. */
133
+ pressure?: { memory?: DiagnosticsPressure; io?: DiagnosticsPressure; cpu?: DiagnosticsPressure };
127
134
  disks: DiagnosticsDiskMetrics[];
128
135
  }
129
136
 
137
+ /** The share (0 to 100) of the last 10, 60 and 300 seconds that tasks were stalled. `some`: at least one waited. `full`: every one did. */
138
+ export interface DiagnosticsPressure {
139
+ some: { avg10: number; avg60: number; avg300: number };
140
+ full?: { avg10: number; avg60: number; avg300: number };
141
+ }
142
+
130
143
  /** From the metrics API (metrics-server): absent when it is not installed. */
131
144
  export interface DiagnosticsPodMetrics {
132
145
  name: string;
@@ -136,8 +149,11 @@ export interface DiagnosticsPodMetrics {
136
149
  }
137
150
 
138
151
  /**
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.
152
+ * `usedBytes` (what the volume holds) exists only for a volume the server pod mounts (`mountedByServer`), and `measuredBy`
153
+ * says how it was found: from the volume's own filesystem, or - when the volume is a directory of the node's disk
154
+ * (`sharesNodeDisk`, such as k3s's local-path), where the filesystem is the whole disk - by measuring the directory.
155
+ * `capacityBytes` is the volume's allocation, which a volume that shares the node's disk does not enforce; `availableBytes` is
156
+ * what is left of the filesystem it is on.
141
157
  */
142
158
  export interface DiagnosticsPvcMetrics {
143
159
  name: string;
@@ -147,6 +163,9 @@ export interface DiagnosticsPvcMetrics {
147
163
  capacityBytes?: number;
148
164
  usedBytes?: number;
149
165
  availableBytes?: number;
166
+ measuredBy?: "filesystem" | "directory";
167
+ /** The directory could not be measured completely, so `usedBytes` is a lower bound. */
168
+ usedPartial?: boolean;
150
169
  mountedByServer: boolean;
151
170
  sharesNodeDisk?: boolean;
152
171
  }
@@ -27,7 +27,7 @@ import {
27
27
  } from "react-icons/hi2";
28
28
  import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
29
29
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
30
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
30
+ import { useSessionRefresh } from "@rapidmx/react-shared/auth/session.js";
31
31
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
32
32
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
33
33
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -197,7 +197,7 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
197
197
  const { branding, iconSrc } = useBranding();
198
198
  const footer = useBrandingHtml(branding?.footerHtml);
199
199
 
200
- useRedirectIfUnauthenticated(userUid, authServerUrl);
200
+ useSessionRefresh(userUid, authServerUrl);
201
201
 
202
202
  useEffect(() => {
203
203
  if (!userUid) {