@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.
- package/README.md +1 -1
- package/apps/shared/components/admin/diagnostics/HostCard.tsx +2 -0
- package/apps/shared/components/admin/diagnostics/PressureTiles.tsx +108 -0
- package/apps/shared/components/admin/diagnostics/PvcTable.tsx +20 -6
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +21 -2
- package/apps/shared/components/admin/layout/AdminShell.tsx +2 -2
- package/apps/shared/components/admin/settings/PluginsManager.tsx +508 -35
- package/apps/shared/components/admin/settings/pluginPreferences.ts +34 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +2 -2
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/mail/ConversationList.tsx +18 -9
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +24 -0
- package/apps/shared/components/mail/listPreferences.ts +3 -3
- package/apps/www/index.tsx +62 -12
- package/dist/apps/shared/components/admin/diagnostics/HostCard.js +2 -1
- package/dist/apps/shared/components/admin/diagnostics/PressureTiles.d.ts +22 -0
- package/dist/apps/shared/components/admin/diagnostics/PressureTiles.js +52 -0
- package/dist/apps/shared/components/admin/diagnostics/PvcTable.js +9 -4
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +35 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +267 -35
- package/dist/apps/shared/components/admin/settings/pluginPreferences.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/pluginPreferences.js +35 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +2 -2
- package/dist/apps/shared/components/layout/AppShell.js +4 -2
- package/dist/apps/shared/components/mail/ConversationList.d.ts +10 -3
- package/dist/apps/shared/components/mail/ConversationList.js +9 -5
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +7 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +20 -1
- package/dist/apps/shared/components/mail/listPreferences.d.ts +1 -1
- package/dist/apps/www/index.js +51 -10
- 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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
102
|
-
const id = conversation
|
|
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,
|
|
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
|
|
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-${
|
|
154
|
-
const ticked = selectedConversationIds?.has(
|
|
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:
|
|
158
|
+
export function sortConversations<T extends ConversationSummary>(
|
|
159
|
+
conversations: T[],
|
|
160
160
|
sortBy: MessageListSort,
|
|
161
161
|
sortOrder: MessageSortOrder,
|
|
162
|
-
):
|
|
162
|
+
): T[] {
|
|
163
163
|
if (!CONVERSATION_SORTS.includes(sortBy)) {
|
|
164
164
|
return conversations;
|
|
165
165
|
}
|
package/apps/www/index.tsx
CHANGED
|
@@ -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 =
|
|
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<
|
|
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
|
-
//
|
|
1059
|
-
//
|
|
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:
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
121
|
-
*
|
|
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 {
|
|
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
|
-
|
|
121
|
+
useSessionRefresh(userUid, authServerUrl);
|
|
122
122
|
useEffect(() => {
|
|
123
123
|
if (!userUid) {
|
|
124
124
|
return;
|