@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
@@ -0,0 +1,34 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Whether the Plugins page offers prerelease versions (`1.0.0-beta.2`) of plugins - for an update, in the version lists
7
+ * and in a plugin search. Stored in `localStorage`, so it is a preference of this browser, not a setting the server
8
+ * keeps: it only decides what the administrator is shown, since an administrator can already install any published
9
+ * version by name. Off unless it was turned on.
10
+ *
11
+ * A read that finds nothing, or a `localStorage` that throws (private-browsing/storage-blocked contexts), is off, and a
12
+ * write that fails is swallowed, so a blocked store only means the choice doesn't survive a reload.
13
+ */
14
+ const STORAGE_KEY = "rapidmx:plugins-allow-prerelease";
15
+
16
+ export function getAllowPrerelease(): boolean {
17
+ try {
18
+ return localStorage.getItem(STORAGE_KEY) === "true";
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export function setAllowPrerelease(allow: boolean): void {
25
+ try {
26
+ if (allow) {
27
+ localStorage.setItem(STORAGE_KEY, "true");
28
+ } else {
29
+ localStorage.removeItem(STORAGE_KEY);
30
+ }
31
+ } catch {
32
+ // Not persisted; the choice still applies until the page is reloaded.
33
+ }
34
+ }
@@ -7,7 +7,7 @@ import React, { PropsWithChildren, ReactNode, useEffect, useState } from "react"
7
7
  import { HiOutlineClipboardDocumentList, HiOutlineFolderOpen } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
10
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
+ import { useSessionRefresh } from "@rapidmx/react-shared/auth/session.js";
11
11
  import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
12
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
13
  import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
@@ -54,7 +54,7 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
54
54
  const [error, setError] = useState<string | null>(null);
55
55
  const [iconSrc, setIconSrc] = useState(DEFAULT_ICON_SRC);
56
56
 
57
- useRedirectIfUnauthenticated(userUid, authServerUrl);
57
+ useSessionRefresh(userUid, authServerUrl);
58
58
 
59
59
  // Only the icon is taken from branding here. The escrow console deliberately skips `useBranding()`, which
60
60
  // would inject the custom stylesheet, and never renders the admin-configured header/footer HTML - this
@@ -13,7 +13,7 @@ import {
13
13
  HiOutlineUsers,
14
14
  } from "react-icons/hi2";
15
15
  import { useRouter } from "@rapidrest/react/client";
16
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
16
+ import { useSessionRefresh } from "@rapidmx/react-shared/auth/session.js";
17
17
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
18
18
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
19
19
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
@@ -202,7 +202,9 @@ export function AppChrome({
202
202
  // A meeting reminder pops up on whichever page they are - see the hook.
203
203
  useCalendarReminders({ userUid, enabled: inFrame });
204
204
 
205
- useRedirectIfUnauthenticated(userUid, authServerUrl);
205
+ // A refused refresh means the sign-in has ended: open compose windows save what they hold (the access token still works for a while) and the
206
+ // browser goes to sign-in. Not while viewing as another user - the refresh cookie is the admin's own, and refreshing would end the impersonation.
207
+ useSessionRefresh(userUid, authServerUrl, { paused: impersonating, beforeRedirect: () => flushComposeDrafts(LOGOUT_TIMEOUT_MS) });
206
208
  // Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
207
209
  // Sign in action (see `notifySessionExpired()`), whichever request noticed first, a background refresh included.
208
210
  useEffect(() => {
@@ -14,9 +14,18 @@ import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass,
14
14
  import SwipeRow from "./SwipeRow.js";
15
15
  import InviteRowChip from "./invite/InviteRowChip.js";
16
16
 
17
+ /** A conversation row of the list. `mailboxUid` is set only by a view that merges several mailboxes ("All Mailboxes"), whose rows are
18
+ * each from a different one; a list of one mailbox's own leaves it out and every row is that mailbox's. */
19
+ export type ListedConversation = ConversationSummary & { mailboxUid?: string };
20
+
21
+ /** What tells a row apart from the others: the same conversation id in two mailboxes (a message sent to both) is two rows. */
22
+ export function conversationRowKey(conversation: ListedConversation): string {
23
+ return conversation.mailboxUid ? `${conversation.mailboxUid}\u0000${conversation.conversationId}` : conversation.conversationId;
24
+ }
25
+
17
26
  export interface ConversationListProps {
18
- conversations: ConversationSummary[];
19
- /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. */
27
+ conversations: ListedConversation[];
28
+ /** The mailbox the conversations were listed from - `listConversationMessages()` is mailbox-scoped. A row that names its own mailbox uses that. */
20
29
  mailboxUid: string;
21
30
  /** The message currently open in the reading pane, so the row standing for it can be marked. */
22
31
  selectedUid: string | null;
@@ -25,7 +34,7 @@ export interface ConversationListProps {
25
34
  * stands for, or the conversation's own `latestMessageUid` for a parent row. The conversation goes
26
35
  * with the uid because the pane shows the whole thread, not just that message.
27
36
  */
28
- onOpenMessage: (conversation: ConversationSummary, uid: string) => void;
37
+ onOpenMessage: (conversation: ListedConversation, uid: string) => void;
29
38
  /** Newer copies of messages this list already fetched - the reading pane marks the message it opens as
30
39
  * read, which this list would otherwise keep showing as unread until the thread is collapsed and
31
40
  * expanded again. */
@@ -98,8 +107,8 @@ export default function ConversationList({
98
107
  setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
99
108
  }
100
109
 
101
- function toggle(conversation: ConversationSummary) {
102
- const id = conversation.conversationId;
110
+ function toggle(conversation: ListedConversation) {
111
+ const id = conversationRowKey(conversation);
103
112
  setExpanded((prev) => {
104
113
  const next = new Set(prev);
105
114
  if (next.has(id)) {
@@ -118,7 +127,7 @@ export default function ConversationList({
118
127
  delete next[id];
119
128
  return next;
120
129
  });
121
- listConversationMessages(mailboxUid, id)
130
+ listConversationMessages(conversation.mailboxUid ?? mailboxUid, conversation.conversationId)
122
131
  .then((loaded) => setMessagesById((prev) => ({ ...prev, [id]: loaded })))
123
132
  .catch((err) =>
124
133
  setErrorsById((prev) => ({
@@ -143,15 +152,15 @@ export default function ConversationList({
143
152
  // `overflow-x-clip`: the panels a swiped row drags along stand outside its edges (see `SwipeRow`).
144
153
  <ul className={swipe?.enabled ? "overflow-x-clip" : undefined}>
145
154
  {conversations.map((conversation) => {
146
- const id = conversation.conversationId;
155
+ const id = conversationRowKey(conversation);
147
156
  const isExpanded = expanded.has(id);
148
157
  const unread = conversation.unreadCount > 0;
149
158
  const loaded = messagesById[id];
150
159
  // `listConversationMessages()` answers oldest first; the rows read in whichever sense the
151
160
  // list itself is arranged in. Copied before reversing - the fetched array is cached.
152
161
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
153
- const panelId = `conversation-messages-${id}`;
154
- const ticked = selectedConversationIds?.has(id) ?? false;
162
+ const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
163
+ const ticked = selectedConversationIds?.has(conversation.conversationId) ?? false;
155
164
  return (
156
165
  <li key={id}>
157
166
  <SwipeRow
@@ -69,6 +69,10 @@ export interface ComposeWindowProps {
69
69
  /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
70
70
  * only overridden by tests. */
71
71
  autosaveDelayMs?: number;
72
+ /** The longest (ms) an unsaved edit waits before the draft is saved even though typing hasn't paused - so a
73
+ * message written without a 2-second break is still saved regularly. Defaults to `DEFAULT_AUTOSAVE_MAX_WAIT_MS`
74
+ * and is never shorter than `autosaveDelayMs`; only overridden by tests. */
75
+ autosaveMaxWaitMs?: number;
72
76
  /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
73
77
  * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
74
78
  cryptoRetryDelaysMs?: number[];
@@ -76,6 +80,8 @@ export interface ComposeWindowProps {
76
80
 
77
81
  /** Debounce between the last edit and the draft autosave. */
78
82
  export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
83
+ /** The longest an edit stays unsaved while the user keeps typing (the debounce above restarts on every keystroke, so it alone never fires then). */
84
+ export const DEFAULT_AUTOSAVE_MAX_WAIT_MS = 30_000;
79
85
 
80
86
  /** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
81
87
  * in the background, without a word to the user. */
@@ -192,6 +198,7 @@ export default function ComposeWindow({
192
198
  userUid,
193
199
  trusted,
194
200
  autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
201
+ autosaveMaxWaitMs = DEFAULT_AUTOSAVE_MAX_WAIT_MS,
195
202
  cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
196
203
  }: ComposeWindowProps) {
197
204
  const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
@@ -296,6 +303,8 @@ export default function ComposeWindow({
296
303
  const lastSavedRef = useRef<string | null>(null);
297
304
  /** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
298
305
  const pendingSaveRef = useRef(false);
306
+ /** Fires the save when an edit has waited `autosaveMaxWaitMs` without the debounce ever getting a pause to fire in. */
307
+ const maxWaitTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
299
308
  /** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
300
309
  const submittingRef = useRef(false);
301
310
  /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
@@ -885,6 +894,8 @@ export default function ComposeWindow({
885
894
  const target = latestRef.current.draft!;
886
895
  const previous = saveInFlightRef.current;
887
896
  pendingSaveRef.current = false;
897
+ clearTimeout(maxWaitTimerRef.current);
898
+ maxWaitTimerRef.current = undefined;
888
899
  setSaveStatus("saving");
889
900
  const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
890
901
  const before = await previous;
@@ -1164,8 +1175,20 @@ export default function ComposeWindow({
1164
1175
  lastSavedRef.current !== `${draft.uid}:${contentKey}`;
1165
1176
  pendingSaveRef.current = due;
1166
1177
  if (!due) {
1178
+ clearTimeout(maxWaitTimerRef.current);
1179
+ maxWaitTimerRef.current = undefined;
1167
1180
  return;
1168
1181
  }
1182
+ // The debounce below restarts on every edit, so steady typing never lets it fire: this timer, started by the first unsaved edit and stopped
1183
+ // by the save that follows, is what saves such a message at a regular interval.
1184
+ if (maxWaitTimerRef.current === undefined) {
1185
+ maxWaitTimerRef.current = setTimeout(() => {
1186
+ maxWaitTimerRef.current = undefined;
1187
+ if (pendingSaveRef.current && !finishedRef.current) {
1188
+ void saveDraftNow();
1189
+ }
1190
+ }, Math.max(autosaveMaxWaitMs, autosaveDelayMs));
1191
+ }
1169
1192
  const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
1170
1193
  return () => clearTimeout(timer);
1171
1194
  }, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
@@ -1174,6 +1197,7 @@ export default function ComposeWindow({
1174
1197
  // right away rather than losing it.
1175
1198
  useEffect(
1176
1199
  () => () => {
1200
+ clearTimeout(maxWaitTimerRef.current);
1177
1201
  if (pendingSaveRef.current && !finishedRef.current) {
1178
1202
  void saveDraftNow();
1179
1203
  }
@@ -155,11 +155,11 @@ function compareConversations(a: ConversationSummary, b: ConversationSummary, so
155
155
  * order that would mean nothing. Ties break newest first whichever direction is in force, so two rows the
156
156
  * key can't tell apart still read in a stable, familiar order.
157
157
  */
158
- export function sortConversations(
159
- conversations: ConversationSummary[],
158
+ export function sortConversations<T extends ConversationSummary>(
159
+ conversations: T[],
160
160
  sortBy: MessageListSort,
161
161
  sortOrder: MessageSortOrder,
162
- ): ConversationSummary[] {
162
+ ): T[] {
163
163
  if (!CONVERSATION_SORTS.includes(sortBy)) {
164
164
  return conversations;
165
165
  }
@@ -68,7 +68,7 @@ import { setReadStateMany } from "../shared/mail/messageReadState.js";
68
68
  import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
69
69
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
70
70
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
71
- import ConversationList from "../shared/components/mail/ConversationList.js";
71
+ import ConversationList, { ListedConversation, conversationRowKey } from "../shared/components/mail/ConversationList.js";
72
72
  import type { ConversationThreadHead } from "../shared/components/mail/ConversationThreadPane.js";
73
73
  import SwipeRow from "../shared/components/mail/SwipeRow.js";
74
74
  import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
@@ -462,7 +462,7 @@ function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string, ca
462
462
  }
463
463
 
464
464
  const messageUid = (message: Message) => message.uid;
465
- const conversationKey = (conversation: ConversationSummary) => conversation.conversationId;
465
+ const conversationKey = conversationRowKey;
466
466
 
467
467
  /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
468
468
  * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
@@ -507,6 +507,30 @@ async function fetchAggregateMessages(
507
507
  return mergeInboxMessages(perMailbox);
508
508
  }
509
509
 
510
+ /**
511
+ * The conversation-list counterpart of `fetchAggregateMessages()`: one `listConversations()` per accessible mailbox that has a folder of
512
+ * `type`, each scoped to *that folder* (so "All Inboxes" groups only Inbox mail - not the Sent Items copy of a reply, which a mailbox-wide
513
+ * grouping pulls in) and each row tagged with its mailbox, then merged newest activity first. Same first-page-only scope, and the same
514
+ * rule that a mailbox that has no such folder or fails to answer contributes nothing rather than blanking the rest.
515
+ */
516
+ async function fetchAggregateConversations(
517
+ mailboxFolders: MailboxFolders[],
518
+ type: AggregateFolderType,
519
+ params: ConversationListParams,
520
+ ): Promise<ListedConversation[]> {
521
+ const perMailbox = await Promise.all(
522
+ mailboxFolders.map(async ({ mailbox, folders }): Promise<ListedConversation[]> => {
523
+ const folder = folders.find((f) => f.type === type);
524
+ if (!folder) {
525
+ return [];
526
+ }
527
+ const rows = await listConversations(mailbox.uid, { ...params, folderUid: folder.uid, page: 0 }).catch(() => [] as ConversationSummary[]);
528
+ return rows.map((row) => ({ ...row, mailboxUid: mailbox.uid }));
529
+ }),
530
+ );
531
+ return perMailbox.flat().sort((a, b) => Date.parse(b.latestDate) - Date.parse(a.latestDate));
532
+ }
533
+
510
534
  /** One conversation's part of a set of search results. */
511
535
  interface ResultGroup {
512
536
  /** The conversation's id - a message that belongs to no thread is its own conversation. */
@@ -547,7 +571,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
547
571
  const navigate = useNavigate();
548
572
  const { requestUnlock } = useUnlockPrompt();
549
573
  const [messages, setMessages] = useState<Message[]>([]);
550
- const [conversations, setConversations] = useState<ConversationSummary[]>([]);
574
+ const [conversations, setConversations] = useState<ListedConversation[]>([]);
551
575
  const [loading, setLoading] = useState(true);
552
576
  // Once a list is on screen and the browser has nothing better to do, fetch the code a click on a message (the reading pane)
553
577
  // or on Compose/Reply (the compose window, with its editor) would otherwise wait for.
@@ -1054,12 +1078,28 @@ function InboxContent({ userUid }: { userUid?: string }) {
1054
1078
  return;
1055
1079
  }
1056
1080
 
1081
+ if (asConversations && aggregateFolderType) {
1082
+ // Grouped per mailbox, inside that mailbox's folder of this type, and merged - first pages only, like the message listing.
1083
+ setLoading(true);
1084
+ setError(null);
1085
+ setHasMore(false);
1086
+ void fetchAggregateConversations(mailboxFolders, aggregateFolderType, conversationParams(0))
1087
+ .then((results) => {
1088
+ if (isCurrentRun()) {
1089
+ setConversations(results);
1090
+ }
1091
+ })
1092
+ .finally(() => {
1093
+ if (isCurrentRun()) {
1094
+ setLoading(false);
1095
+ }
1096
+ });
1097
+ return invalidate;
1098
+ }
1099
+
1057
1100
  if (asConversations) {
1058
- // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
1059
- // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
1060
- // (always set - `MailShell` only renders this component once at least one mailbox exists).
1061
- // They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
1062
- // conversation list matches the folder the sidebar has selected rather than the whole mailbox.
1101
+ // A single mailbox's own folder (`folderUid`), so the conversation list matches the folder the sidebar has selected rather
1102
+ // than the whole mailbox.
1063
1103
  if (snapshot) {
1064
1104
  setConversations(snapshot.conversations);
1065
1105
  listedOffsetRef.current = snapshot.conversations.length;
@@ -1431,7 +1471,13 @@ function InboxContent({ userUid }: { userUid?: string }) {
1431
1471
  const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1432
1472
  void (async () => {
1433
1473
  try {
1434
- if (asConversations) {
1474
+ if (asConversations && aggregateFolderType) {
1475
+ // No paging here either: the fresh merged first pages are the list.
1476
+ const fresh = await fetchAggregateConversations(mailboxFolders, aggregateFolderType, conversationParams(0));
1477
+ if (isCurrent()) {
1478
+ setConversations(fresh);
1479
+ }
1480
+ } else if (asConversations) {
1435
1481
  const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1436
1482
  if (!isCurrent()) {
1437
1483
  return;
@@ -2267,7 +2313,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2267
2313
 
2268
2314
  /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
2269
2315
  * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
2270
- function handleOpenConversation(conversation: ConversationSummary, uid: string) {
2316
+ function handleOpenConversation(conversation: ListedConversation, uid: string) {
2271
2317
  if (selectMode) {
2272
2318
  // Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
2273
2319
  // the row rather than opening it.
@@ -2281,7 +2327,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
2281
2327
  }
2282
2328
  removedAnchorRef.current = null;
2283
2329
  setSelectedUid(uid);
2284
- setOpenThread({ conversation, uid, mailboxUid: activeMailboxUid });
2330
+ setOpenThread({ conversation, uid, mailboxUid: conversation.mailboxUid ?? activeMailboxUid });
2285
2331
  }
2286
2332
 
2287
2333
  // ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
@@ -2320,7 +2366,11 @@ function InboxContent({ userUid }: { userUid?: string }) {
2320
2366
  /** The index of the selected row: the open message's, or the open conversation's; -1 with nothing selected. */
2321
2367
  function selectedRowIndex(): number {
2322
2368
  if (inConversations) {
2323
- return openThread ? listedConversations.findIndex((c) => c.conversationId === openThread.conversation.conversationId) : -1;
2369
+ return openThread
2370
+ ? listedConversations.findIndex(
2371
+ (c) => c.conversationId === openThread.conversation.conversationId && (!c.mailboxUid || c.mailboxUid === openThread.mailboxUid),
2372
+ )
2373
+ : -1;
2324
2374
  }
2325
2375
  return messages.findIndex((m) => m.uid === selectedUid);
2326
2376
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { formatBytes, formatCores, formatPercent, NO_VALUE, percentOf } from "./format.js";
3
3
  import { seriesOf } from "./metricsHistory.js";
4
4
  import MetricTile from "./MetricTile.js";
5
+ import PressureTiles from "./PressureTiles.js";
5
6
  import UsageMeter from "./UsageMeter.js";
6
7
  const percentLabel = (value) => formatPercent(value);
7
8
  /** `1.23, 0.98, 0.5`, or a dash for a load average the server left out. */
@@ -29,5 +30,5 @@ export default function HostCard({ host, history }) {
29
30
  values: seriesOf(history, (sample) => percentOf(sample.host?.memoryUsedBytes, sample.host?.memoryTotalBytes)),
30
31
  max: 100,
31
32
  format: percentLabel,
32
- } }), _jsx(MetricTile, { label: "Load average", value: formatLoad(host.loadAverage), detail: "1, 5 and 15 minutes" })] }), _jsx("h3", { className: "mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted", children: "Disks" }), disks.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No disks were reported." })) : (_jsx("ul", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: disks.map((disk) => (_jsx(DiskRow, { disk: disk }, disk.path))) }))] }))] }));
33
+ } }), _jsx(MetricTile, { label: "Load average", value: formatLoad(host.loadAverage), detail: "1, 5 and 15 minutes" })] }), _jsx(PressureTiles, { host: host, history: history }), _jsx("h3", { className: "mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted", children: "Disks" }), disks.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No disks were reported." })) : (_jsx("ul", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: disks.map((disk) => (_jsx(DiskRow, { disk: disk }, disk.path))) }))] }))] }));
33
34
  }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import type { DiagnosticsMetrics } from "./diagnosticsApi.js";
3
+ /** From this share of the last 10 seconds spent stalled, pressure is called elevated ... */
4
+ export declare const ELEVATED_PRESSURE_PERCENT = 10;
5
+ /** ... and from this, high. Kernel documentation treats a few percent as ordinary and tens of percent as a machine that is struggling. */
6
+ export declare const HIGH_PRESSURE_PERCENT = 30;
7
+ export type PressureLevel = "Low" | "Elevated" | "High";
8
+ /** Pressure in words, so that it is never only a number or a colour. */
9
+ export declare function pressureLevel(percent: number): PressureLevel;
10
+ type Host = NonNullable<DiagnosticsMetrics["host"]>;
11
+ export interface PressureTilesProps {
12
+ host: Host;
13
+ history: DiagnosticsMetrics[];
14
+ }
15
+ /**
16
+ * How stalled the node is (Linux pressure stall information: the share of recent time tasks spent waiting for memory, the disk or
17
+ * 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
18
+ * uses much: a guest counts ballooned memory as used though no process holds it, and a machine short of memory stalls on the disk.
19
+ * Nothing is shown where the kernel gives no such figures.
20
+ */
21
+ export default function PressureTiles({ host, history }: PressureTilesProps): React.JSX.Element | null;
22
+ export {};
@@ -0,0 +1,52 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { formatBytes, formatPercent, percentOf } from "./format.js";
3
+ import { seriesOf } from "./metricsHistory.js";
4
+ import MetricTile from "./MetricTile.js";
5
+ /** From this share of the last 10 seconds spent stalled, pressure is called elevated ... */
6
+ export const ELEVATED_PRESSURE_PERCENT = 10;
7
+ /** ... and from this, high. Kernel documentation treats a few percent as ordinary and tens of percent as a machine that is struggling. */
8
+ export const HIGH_PRESSURE_PERCENT = 30;
9
+ /** Pressure in words, so that it is never only a number or a colour. */
10
+ export function pressureLevel(percent) {
11
+ if (percent >= HIGH_PRESSURE_PERCENT) {
12
+ return "High";
13
+ }
14
+ return percent >= ELEVATED_PRESSURE_PERCENT ? "Elevated" : "Low";
15
+ }
16
+ const RESOURCES = [
17
+ { key: "memory", label: "Memory pressure", waiting: "waiting for memory" },
18
+ { key: "io", label: "I/O pressure", waiting: "waiting for the disk" },
19
+ { key: "cpu", label: "CPU pressure", waiting: "waiting for a CPU" },
20
+ ];
21
+ function PressureTile({ label, waiting, stall, history, resource }) {
22
+ const level = pressureLevel(stall.some.avg10);
23
+ return (_jsx(MetricTile, { label: label, value: formatPercent(stall.some.avg10), detail: `${level}: tasks ${waiting}, last 10 seconds`, history: {
24
+ values: seriesOf(history, (sample) => sample.host?.pressure?.[resource]?.some.avg10),
25
+ max: 100,
26
+ format: formatPercent,
27
+ }, children: _jsxs("div", { className: "mt-2 text-xs text-text-muted", children: ["Last minute ", formatPercent(stall.some.avg60), ", last 5 minutes ", formatPercent(stall.some.avg300), stall.full && ` · every task waiting: ${formatPercent(stall.full.avg10)}`] }) }));
28
+ }
29
+ /**
30
+ * How stalled the node is (Linux pressure stall information: the share of recent time tasks spent waiting for memory, the disk or
31
+ * 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
32
+ * uses much: a guest counts ballooned memory as used though no process holds it, and a machine short of memory stalls on the disk.
33
+ * Nothing is shown where the kernel gives no such figures.
34
+ */
35
+ export default function PressureTiles({ host, history }) {
36
+ const tiles = RESOURCES.filter(({ key }) => host.pressure?.[key]);
37
+ const balloon = host.balloon;
38
+ if (tiles.length === 0 && !balloon) {
39
+ return null;
40
+ }
41
+ const heldPercent = percentOf(balloon?.heldBytes, host.memoryTotalBytes);
42
+ return (_jsxs(_Fragment, { children: [_jsx("h3", { className: "mt-4 mb-2 text-xs uppercase tracking-wide text-text-muted", children: "Pressure" }), _jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4", children: [RESOURCES.map(({ key, label, waiting }) => {
43
+ const stall = host.pressure?.[key];
44
+ return stall && _jsx(PressureTile, { label: label, waiting: waiting, stall: stall, history: history, resource: key }, key);
45
+ }), balloon && (_jsx(MetricTile, { label: "Held by the hypervisor", value: formatBytes(balloon.heldBytes), detail: `of ${formatBytes(host.memoryTotalBytes)}`, usage: heldPercent === undefined
46
+ ? undefined
47
+ : { percent: heldPercent, detail: `${formatBytes(balloon.heldBytes)} in the memory balloon`, flag: false }, history: {
48
+ values: seriesOf(history, (sample) => sample.host?.balloon?.heldBytes),
49
+ max: host.memoryTotalBytes,
50
+ format: formatBytes,
51
+ }, children: _jsxs("div", { className: "mt-2 text-xs text-text-muted", children: ["Memory the hypervisor has taken back through the balloon. It counts as used above though no process holds it.", " ", formatBytes(balloon.inflatedTotalBytes), " taken since the node booted."] }) }))] })] }));
52
+ }
@@ -1,16 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { formatBytes, NO_VALUE, percentOf } from "./format.js";
3
3
  import UsageMeter from "./UsageMeter.js";
4
- const HEADINGS = ["Volume", "Phase", "Storage class", "Size", "Usage"];
4
+ const HEADINGS = ["Volume", "Phase", "Storage class", "Allocated", "Usage"];
5
5
  function Usage({ pvc }) {
6
6
  if (!pvc.mountedByServer) {
7
7
  return _jsx("span", { className: "text-text-muted", children: "Usage not available: not mounted in the server pod." });
8
8
  }
9
- const percent = percentOf(pvc.usedBytes, pvc.capacityBytes);
9
+ // On the node's disk the filesystem's figures are the disk's, so only a measured directory says what the volume holds (an
10
+ // older server sent the disk's figures without saying so).
11
+ const own = pvc.sharesNodeDisk && pvc.measuredBy !== "directory" ? undefined : pvc.usedBytes;
12
+ const percent = percentOf(own, pvc.capacityBytes);
10
13
  if (percent === undefined) {
11
- return _jsx("span", { className: "text-text-muted", children: "The server has no usage figures for this volume." });
14
+ return (_jsx("span", { className: "text-text-muted", children: pvc.sharesNodeDisk
15
+ ? "Shares the node\u2019s disk, and what the volume holds could not be measured."
16
+ : "The server has no usage figures for this volume." }));
12
17
  }
13
- return (_jsxs("div", { children: [_jsx(UsageMeter, { label: `Usage of volume ${pvc.name}`, percent: percent, detail: `${formatBytes(pvc.usedBytes)} of ${formatBytes(pvc.capacityBytes)}`, flag: !pvc.sharesNodeDisk }), pvc.sharesNodeDisk && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Shares the node\u2019s disk; the volume\u2019s size is not enforced." }))] }));
18
+ return (_jsxs("div", { children: [_jsx(UsageMeter, { label: `Usage of volume ${pvc.name}`, percent: percent, detail: `${pvc.usedPartial ? "At least " : ""}${formatBytes(own)} of ${formatBytes(pvc.capacityBytes)} allocated` }), pvc.usedPartial && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "The volume is too large to measure completely, so this is a lower bound." })), pvc.sharesNodeDisk && (_jsxs("p", { className: "mt-1 text-xs text-text-muted", children: ["Measured from the volume\u2019s directory. It shares the node\u2019s disk, which has ", formatBytes(pvc.availableBytes), " free; the volume\u2019s allocation is not enforced."] }))] }));
14
19
  }
15
20
  /** The install's persistent volume claims, with a usage bar for the ones the server can measure. */
16
21
  export default function PvcTable({ pvcs }) {
@@ -107,8 +107,35 @@ export interface DiagnosticsHostMetrics {
107
107
  memoryTotalBytes: number;
108
108
  memoryUsedBytes: number;
109
109
  loadAverage: [number, number, number];
110
+ /**
111
+ * Memory the hypervisor holds in the machine's memory balloon. It counts in `memoryUsedBytes` (a guest sees ballooned memory
112
+ * as used) though no process holds it. Absent where no balloon has ever taken any.
113
+ */
114
+ balloon?: {
115
+ heldBytes: number;
116
+ inflatedTotalBytes: number;
117
+ };
118
+ /** How stalled the node is waiting for memory, disk and CPU (Linux pressure stall information). Absent without kernel support. */
119
+ pressure?: {
120
+ memory?: DiagnosticsPressure;
121
+ io?: DiagnosticsPressure;
122
+ cpu?: DiagnosticsPressure;
123
+ };
110
124
  disks: DiagnosticsDiskMetrics[];
111
125
  }
126
+ /** 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. */
127
+ export interface DiagnosticsPressure {
128
+ some: {
129
+ avg10: number;
130
+ avg60: number;
131
+ avg300: number;
132
+ };
133
+ full?: {
134
+ avg10: number;
135
+ avg60: number;
136
+ avg300: number;
137
+ };
138
+ }
112
139
  /** From the metrics API (metrics-server): absent when it is not installed. */
113
140
  export interface DiagnosticsPodMetrics {
114
141
  name: string;
@@ -117,8 +144,11 @@ export interface DiagnosticsPodMetrics {
117
144
  memoryUsedBytes?: number;
118
145
  }
119
146
  /**
120
- * `usedBytes`/`availableBytes` exist only for a volume the server pod mounts (`mountedByServer`). `sharesNodeDisk` means the
121
- * figures are the whole node filesystem's, and the volume's own size is not enforced.
147
+ * `usedBytes` (what the volume holds) exists only for a volume the server pod mounts (`mountedByServer`), and `measuredBy`
148
+ * says how it was found: from the volume's own filesystem, or - when the volume is a directory of the node's disk
149
+ * (`sharesNodeDisk`, such as k3s's local-path), where the filesystem is the whole disk - by measuring the directory.
150
+ * `capacityBytes` is the volume's allocation, which a volume that shares the node's disk does not enforce; `availableBytes` is
151
+ * what is left of the filesystem it is on.
122
152
  */
123
153
  export interface DiagnosticsPvcMetrics {
124
154
  name: string;
@@ -128,6 +158,9 @@ export interface DiagnosticsPvcMetrics {
128
158
  capacityBytes?: number;
129
159
  usedBytes?: number;
130
160
  availableBytes?: number;
161
+ measuredBy?: "filesystem" | "directory";
162
+ /** The directory could not be measured completely, so `usedBytes` is a lower bound. */
163
+ usedPartial?: boolean;
131
164
  mountedByServer: boolean;
132
165
  sharesNodeDisk?: boolean;
133
166
  }
@@ -9,7 +9,7 @@ import { useRouter } from "@rapidrest/react/client";
9
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
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
12
+ import { useSessionRefresh } from "@rapidmx/react-shared/auth/session.js";
13
13
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
14
14
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
15
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -118,7 +118,7 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
118
118
  // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
119
119
  const { branding, iconSrc } = useBranding();
120
120
  const footer = useBrandingHtml(branding?.footerHtml);
121
- useRedirectIfUnauthenticated(userUid, authServerUrl);
121
+ useSessionRefresh(userUid, authServerUrl);
122
122
  useEffect(() => {
123
123
  if (!userUid) {
124
124
  return;