@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,37 @@
1
+ import { LogClientOptions, LogStreamStatus } from "./logClient.js";
2
+ import type { LogEntry } from "./logLines.js";
3
+ /** Lines arrive in bursts; the view is redrawn at most this often, however many came. */
4
+ export declare const LOG_FLUSH_MS = 100;
5
+ /** What a capture has recorded, without the lines (which can be tens of thousands: `LogStream.captureEntries()` has them). */
6
+ export interface CaptureSummary {
7
+ active: boolean;
8
+ startedAt: number;
9
+ stoppedAt: number | undefined;
10
+ count: number;
11
+ missed: number;
12
+ }
13
+ export interface LogStream {
14
+ status: LogStreamStatus;
15
+ /** Why the stream is in the `error` state. */
16
+ error: string | undefined;
17
+ /** Whether the stream is on (connecting, live or trying again), as opposed to stopped or refused. */
18
+ running: boolean;
19
+ /** The display buffer: the most recent lines, oldest first. */
20
+ entries: LogEntry[];
21
+ /** How many older lines the display buffer has dropped. */
22
+ dropped: number;
23
+ capture: CaptureSummary | undefined;
24
+ start: () => void;
25
+ stop: () => void;
26
+ /** Empties the display buffer (not a capture). */
27
+ clear: () => void;
28
+ startCapture: () => void;
29
+ stopCapture: () => void;
30
+ /** Every line of the capture, for a download. */
31
+ captureEntries: () => LogEntry[];
32
+ }
33
+ /**
34
+ * The live log stream of the server for the log view: a `LogClient`, and a `LogStore` holding the lines and any capture. Nothing
35
+ * connects until `start()`. The stream is closed, and the redraw timer cleared, when the component unmounts.
36
+ */
37
+ export declare function useLogStream(options?: LogClientOptions): LogStream;
@@ -0,0 +1,79 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { LogClient } from "./logClient.js";
7
+ import { LogStore } from "./LogStore.js";
8
+ /** Lines arrive in bursts; the view is redrawn at most this often, however many came. */
9
+ export const LOG_FLUSH_MS = 100;
10
+ function snapshotOf(store) {
11
+ const capture = store.capture;
12
+ return {
13
+ entries: store.entries.slice(),
14
+ dropped: store.dropped,
15
+ capture: capture && {
16
+ active: capture.active,
17
+ startedAt: capture.startedAt,
18
+ stoppedAt: capture.stoppedAt,
19
+ count: capture.entries.length,
20
+ missed: capture.missed,
21
+ },
22
+ };
23
+ }
24
+ /**
25
+ * The live log stream of the server for the log view: a `LogClient`, and a `LogStore` holding the lines and any capture. Nothing
26
+ * connects until `start()`. The stream is closed, and the redraw timer cleared, when the component unmounts.
27
+ */
28
+ export function useLogStream(options = {}) {
29
+ const [client] = useState(() => new LogClient(options));
30
+ const [store] = useState(() => new LogStore());
31
+ const [connection, setConnection] = useState({
32
+ status: client.status,
33
+ error: undefined,
34
+ });
35
+ const [snapshot, setSnapshot] = useState(() => snapshotOf(store));
36
+ const timer = useRef(undefined);
37
+ const flush = useCallback(() => {
38
+ clearTimeout(timer.current);
39
+ timer.current = undefined;
40
+ setSnapshot(snapshotOf(store));
41
+ }, [store]);
42
+ useEffect(() => {
43
+ const stopEntries = client.onEntry((entry) => {
44
+ store.add(entry);
45
+ timer.current ?? (timer.current = setTimeout(flush, LOG_FLUSH_MS));
46
+ });
47
+ const stopStatus = client.onStatus((status, error) => setConnection({ status, error }));
48
+ return () => {
49
+ stopEntries();
50
+ stopStatus();
51
+ client.stop();
52
+ clearTimeout(timer.current);
53
+ timer.current = undefined;
54
+ };
55
+ }, [client, store, flush]);
56
+ return {
57
+ status: connection.status,
58
+ error: connection.error,
59
+ running: connection.status === "connecting" || connection.status === "live" || connection.status === "reconnecting",
60
+ entries: snapshot.entries,
61
+ dropped: snapshot.dropped,
62
+ capture: snapshot.capture,
63
+ start: () => client.start(),
64
+ stop: () => client.stop(),
65
+ clear: () => {
66
+ store.clear();
67
+ flush();
68
+ },
69
+ startCapture: () => {
70
+ store.startCapture(Date.now());
71
+ flush();
72
+ },
73
+ stopCapture: () => {
74
+ store.stopCapture(Date.now());
75
+ flush();
76
+ },
77
+ captureEntries: () => store.capture?.entries ?? [],
78
+ };
79
+ }
@@ -0,0 +1,20 @@
1
+ import { DiagnosticsMetrics } from "./diagnosticsApi.js";
2
+ export interface MetricsPolling {
3
+ /** The last `METRICS_HISTORY_SIZE` samples, oldest first. */
4
+ history: DiagnosticsMetrics[];
5
+ /** Why the last request failed, until one succeeds. */
6
+ error: string | undefined;
7
+ /** Whether nothing has been answered yet. */
8
+ loading: boolean;
9
+ /** Whether polling is stopped by the viewer (Pause), or because the server refused the viewer. */
10
+ paused: boolean;
11
+ setPaused: (paused: boolean) => void;
12
+ }
13
+ /**
14
+ * Samples `getDiagnosticsMetrics()` every `METRICS_POLL_INTERVAL_MS` while `enabled` and not paused, keeping the last
15
+ * `METRICS_HISTORY_SIZE`. A request is only ever made when the previous one has answered, none is made while the page is
16
+ * hidden (one is made at once when it is shown again), and polling stops when the component unmounts. A refusal (401/403)
17
+ * pauses polling, since asking again would only be refused again; Resume tries once more. Any other failure is shown
18
+ * and polling carries on, so a blip on the server does not need the viewer to do anything.
19
+ */
20
+ export declare function useMetricsPolling(enabled: boolean): MetricsPolling;
@@ -0,0 +1,69 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import { getDiagnosticsMetrics } from "./diagnosticsApi.js";
7
+ import { describeError, isPermissionError } from "./format.js";
8
+ import { appendSample, METRICS_POLL_INTERVAL_MS } from "./metricsHistory.js";
9
+ /**
10
+ * Samples `getDiagnosticsMetrics()` every `METRICS_POLL_INTERVAL_MS` while `enabled` and not paused, keeping the last
11
+ * `METRICS_HISTORY_SIZE`. A request is only ever made when the previous one has answered, none is made while the page is
12
+ * hidden (one is made at once when it is shown again), and polling stops when the component unmounts. A refusal (401/403)
13
+ * pauses polling, since asking again would only be refused again; Resume tries once more. Any other failure is shown
14
+ * and polling carries on, so a blip on the server does not need the viewer to do anything.
15
+ */
16
+ export function useMetricsPolling(enabled) {
17
+ const [history, setHistory] = useState([]);
18
+ const [error, setError] = useState();
19
+ const [loading, setLoading] = useState(true);
20
+ const [paused, setPaused] = useState(false);
21
+ useEffect(() => {
22
+ if (!enabled || paused) {
23
+ return;
24
+ }
25
+ let cancelled = false;
26
+ let inFlight = false;
27
+ async function sample() {
28
+ if (inFlight || document.hidden) {
29
+ return;
30
+ }
31
+ inFlight = true;
32
+ try {
33
+ const next = await getDiagnosticsMetrics();
34
+ if (!cancelled) {
35
+ setHistory((previous) => appendSample(previous, next));
36
+ setError(undefined);
37
+ }
38
+ }
39
+ catch (err) {
40
+ if (!cancelled) {
41
+ setError(describeError(err, "Could not read the server's metrics."));
42
+ if (isPermissionError(err)) {
43
+ setPaused(true);
44
+ }
45
+ }
46
+ }
47
+ finally {
48
+ inFlight = false;
49
+ if (!cancelled) {
50
+ setLoading(false);
51
+ }
52
+ }
53
+ }
54
+ void sample();
55
+ const timer = setInterval(() => void sample(), METRICS_POLL_INTERVAL_MS);
56
+ const onVisibilityChange = () => {
57
+ if (!document.hidden) {
58
+ void sample();
59
+ }
60
+ };
61
+ document.addEventListener("visibilitychange", onVisibilityChange);
62
+ return () => {
63
+ cancelled = true;
64
+ clearInterval(timer);
65
+ document.removeEventListener("visibilitychange", onVisibilityChange);
66
+ };
67
+ }, [enabled, paused]);
68
+ return { history, error, loading, paused, setPaused };
69
+ }
@@ -2,7 +2,7 @@ import "../../../styles/app.css";
2
2
  import React, { PropsWithChildren } from "react";
3
3
  import type { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
4
4
  import { PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
5
- export type AdminSection = "mailboxes" | "quarantine" | "ingestQueue" | "domains" | "auditLog" | "distributionLists" | "transportRules" | "escrowScopes" | "retentionPolicy" | "encryptionPolicy" | "mailboxPolicy" | "setup" | "dataRequests" | "plugins" | "branding" | "signingCertificates";
5
+ export type AdminSection = "mailboxes" | "quarantine" | "ingestQueue" | "domains" | "auditLog" | "distributionLists" | "transportRules" | "escrowScopes" | "retentionPolicy" | "encryptionPolicy" | "mailboxPolicy" | "setup" | "dataRequests" | "plugins" | "diagnostics" | "branding" | "signingCertificates";
6
6
  /** A core `AdminSection`, or a plugin's `adminNav` item id (see `PluginNav`). */
7
7
  export type AdminShellActive = AdminSection | (string & {});
8
8
  export interface AdminShellProps extends PluginNavProps {
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import "../../../styles/app.css";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useRouter } from "@rapidrest/react/client";
9
- import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, } from "react-icons/hi2";
9
+ import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineCpuChip, HiOutlineDocumentArrowDown, HiOutlineDocumentCheck, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, HiOutlineWrenchScrewdriver, HiOutlineBars3, } from "react-icons/hi2";
10
10
  import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
11
11
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
12
12
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
@@ -78,6 +78,7 @@ const NAV_ITEMS = [
78
78
  icon: HiOutlineDocumentCheck,
79
79
  },
80
80
  { id: "plugins", href: "/admin/plugins", label: "Plugins", icon: HiOutlinePuzzlePiece },
81
+ { id: "diagnostics", href: "/admin/diagnostics", label: "Diagnostics", icon: HiOutlineCpuChip },
81
82
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
82
83
  ];
83
84
  /** Mailbox-scoped sections — not part of the global nav rail/tab bar (see `NAV_ITEMS` above), but
@@ -616,13 +616,32 @@ function suggestedHost(definition, saved) {
616
616
  const unset = saved === undefined || saved === "" || (typeof saved === "string" && saved.includes(HOST_PLACEHOLDER));
617
617
  return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
618
618
  }
619
- /** A setting's current form value: its saved value, else its default - or, for a required select with neither, its
620
- * first option. Kept as a string for text inputs. */
621
- function initialValue(definition, saved) {
619
+ /** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
620
+ function configuredValue(definition, configured) {
621
+ if (definition.type === "boolean") {
622
+ return configured.value === true;
623
+ }
624
+ return configured.value === undefined ? "" : String(configured.value);
625
+ }
626
+ /** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
627
+ function isUnsaved(saved) {
628
+ return saved === undefined || saved === "";
629
+ }
630
+ /** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
631
+ function hostSuggestion(definition, saved, configured) {
632
+ return configured ? undefined : suggestedHost(definition, saved);
633
+ }
634
+ /** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
635
+ * deployment configures; else the setting's own default - or, for a required select with none of those, its first
636
+ * option. Kept as a string for text inputs. */
637
+ function initialValue(definition, saved, configured) {
638
+ if (configured && isUnsaved(saved)) {
639
+ return configuredValue(definition, configured);
640
+ }
622
641
  if (needsSelection(definition, saved)) {
623
642
  return definition.options[0].value;
624
643
  }
625
- const suggestion = suggestedHost(definition, saved);
644
+ const suggestion = hostSuggestion(definition, saved, configured);
626
645
  if (suggestion !== undefined) {
627
646
  return suggestion;
628
647
  }
@@ -635,28 +654,51 @@ function initialValue(definition, saved) {
635
654
  function SettingsModal({ plugin, onClose, onSaved }) {
636
655
  // Only opened from the Settings button, which is only shown for a plugin that declares settings.
637
656
  const definitions = plugin.manifest.settings;
638
- const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
657
+ const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
639
658
  const [values, setValues] = useState(initial.current);
659
+ // Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
660
+ const [resetting, setResetting] = useState(false);
640
661
  const [error, setError] = useState(null);
641
662
  const [busy, setBusy] = useState(false);
663
+ /** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
664
+ function reset() {
665
+ setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
666
+ setResetting(true);
667
+ setError(null);
668
+ }
642
669
  async function save(e) {
643
670
  e.preventDefault();
644
671
  // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
645
672
  // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
646
673
  // A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
647
- // form shows them.
674
+ // form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
675
+ // - a field that shows it is sent as nothing.
648
676
  const settings = {};
649
677
  let changed = false;
650
678
  for (const definition of definitions) {
651
679
  const key = definition.key;
680
+ const configured = plugin.configured?.[key];
681
+ const saved = plugin.settings[key];
652
682
  const value = values[key];
653
- if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key]) && suggestedHost(definition, plugin.settings[key]) === undefined) {
654
- settings[key] = plugin.settings[key] ?? null;
683
+ if (resetting && value === initialValue(definition, undefined, configured)) {
684
+ // What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
685
+ // A saved value that already is the default shown stays, since dropping it changes nothing.
686
+ settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
687
+ changed || (changed = settings[key] !== (saved ?? null));
688
+ continue;
689
+ }
690
+ if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
691
+ settings[key] = saved ?? null;
655
692
  continue;
656
693
  }
657
694
  changed = true;
658
695
  // A number input only ever reports a valid number or an empty string.
659
- settings[key] = value === "" ? null : definition.type === "number" ? Number(value) : value;
696
+ settings[key] =
697
+ value === "" || (configured && !configured.secret && String(value) === String(configured.value))
698
+ ? null
699
+ : definition.type === "number"
700
+ ? Number(value)
701
+ : value;
660
702
  }
661
703
  if (!changed) {
662
704
  onClose();
@@ -674,15 +716,21 @@ function SettingsModal({ plugin, onClose, onSaved }) {
674
716
  setBusy(false);
675
717
  }
676
718
  }
677
- return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-5", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsxs("div", { className: "sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Saving restarts the servers one at a time to apply the new settings." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Save" })] })] })] })] }));
719
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-5", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], configured: plugin.configured?.[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsxs("div", { className: "sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3", children: [_jsx("p", { className: "text-xs text-text-muted", children: resetting
720
+ ? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
721
+ : "Saving restarts the servers one at a time to apply the new settings." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mr-auto", onClick: reset, disabled: busy, children: "Reset" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Save" })] })] })] })] }));
678
722
  }
679
- function SettingField({ definition, value, onChange, }) {
680
- const help = definition.help && _jsx("span", { className: "text-xs text-text-muted", children: definition.help });
723
+ function SettingField({ definition, value, configured, onChange, }) {
724
+ // What applies without a saved value; one saved here replaces it, and Reset goes back to it.
725
+ const note = configured && (_jsxs("span", { className: "text-xs text-text-muted", children: ["This deployment's configuration provides ", configured.secret ? "a value" : "the value shown", " unless one is saved here."] }));
726
+ // A secret's value never reaches the browser, so the field only says that it is set.
727
+ const placeholder = configured?.secret ? "Set by this deployment" : undefined;
728
+ const help = (_jsxs(_Fragment, { children: [definition.help && _jsx("span", { className: "text-xs text-text-muted", children: definition.help }), note] }));
681
729
  if (definition.type === "boolean") {
682
730
  // Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
683
731
  return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: value === true, onChange: (e) => onChange(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: definition.label }), help] })] }));
684
732
  }
685
- return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
733
+ return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required && !configured, placeholder: placeholder, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
686
734
  }
687
735
  function RemoveModal({ plugin, onClose, onRemoved }) {
688
736
  const [error, setError] = useState(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.17.2",
3
+ "version": "0.19.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",
@@ -40,7 +40,7 @@
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@journeyapps/wa-sqlite": "^2.0.4",
42
42
  "@peculiar/x509": "^2.1.0",
43
- "@rapidmx/react-shared": "^0.19.0",
43
+ "@rapidmx/react-shared": "^0.20.0",
44
44
  "@tiptap/core": "^3.31.3",
45
45
  "@tiptap/extension-highlight": "^3.31.3",
46
46
  "@tiptap/extension-image": "^3.31.3",