@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
@@ -37,6 +37,7 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
37
37
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
38
38
  import { notify } from "../../../notifications/store.js";
39
39
  import { isElevationRequired } from "../elevation.js";
40
+ import { getAllowPrerelease, setAllowPrerelease } from "./pluginPreferences.js";
40
41
 
41
42
  const INPUT_CLASS =
42
43
  "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -91,6 +92,72 @@ interface PendingChange {
91
92
  uid?: string;
92
93
  }
93
94
 
95
+ /** An action the toolbar above the installed plugins applies to several plugins at once. */
96
+ type BulkKind = "upgrade" | "disable" | "uninstall";
97
+
98
+ const BULK_WORDS: Record<BulkKind, { doing: string; done: string }> = {
99
+ upgrade: { doing: "Upgrading", done: "upgraded" },
100
+ disable: { doing: "Disabling", done: "disabled" },
101
+ uninstall: { doing: "Uninstalling", done: "uninstalled" },
102
+ };
103
+
104
+ /** How far a bulk action has got. */
105
+ interface BulkProgress {
106
+ kind: BulkKind;
107
+ /** How many plugins were finished, failures included. */
108
+ done: number;
109
+ total: number;
110
+ /** The plugin being changed now. */
111
+ current: string;
112
+ }
113
+
114
+ /** Why some plugins of a bulk action were left as they were. */
115
+ interface BulkFailure {
116
+ title: string;
117
+ reasons: string[];
118
+ }
119
+
120
+ const pluralPlugins = (count: number): string => `${count} ${count === 1 ? "plugin" : "plugins"}`;
121
+
122
+ /**
123
+ * `plugins` in the order a bulk action has to change them in, since a plugin that others require can't be disabled or
124
+ * uninstalled while they're enabled, and one that requires others may need them at their new versions first: each
125
+ * plugin comes after the ones it requires (`dependenciesFirst`, for an upgrade) or before them (for the rest).
126
+ */
127
+ function orderByRequirements(plugins: Plugin[], dependenciesFirst: boolean): Plugin[] {
128
+ const byName: Map<string, Plugin> = new Map(plugins.map((plugin) => [plugin.name, plugin]));
129
+ const ordered: Plugin[] = [];
130
+ const seen: Set<string> = new Set();
131
+ const visit = (plugin: Plugin) => {
132
+ if (seen.has(plugin.name)) {
133
+ return;
134
+ }
135
+ seen.add(plugin.name);
136
+ for (const required of Object.keys(plugin.manifest.requires ?? {})) {
137
+ const dependency: Plugin | undefined = byName.get(required);
138
+ if (dependency) {
139
+ visit(dependency);
140
+ }
141
+ }
142
+ ordered.push(plugin);
143
+ };
144
+ plugins.forEach(visit);
145
+ return dependenciesFirst ? ordered : ordered.reverse();
146
+ }
147
+
148
+ /**
149
+ * The bar that runs across the top of the window while plugins are being changed, so it's plain that something is still
150
+ * happening: it slides endlessly, since how long a registry lookup or an install takes isn't known. It stands still
151
+ * (as a faded full bar) when the user asks for less motion.
152
+ */
153
+ function PluginProgressBar({ label }: { label: string }) {
154
+ return (
155
+ <div role="progressbar" aria-label={label} className="fixed inset-x-0 top-0 z-[1100] h-1 overflow-hidden bg-primary/20">
156
+ <div className="rr-progress-indeterminate h-full w-1/3 rounded-pill bg-primary motion-reduce:w-full motion-reduce:opacity-40" />
157
+ </div>
158
+ );
159
+ }
160
+
94
161
  /** Classes for a plugin table's header and body cells. */
95
162
  const TH_CLASS = "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border";
96
163
  const TD_CLASS = "py-3 px-2.5 border-b border-border align-top";
@@ -139,6 +206,21 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
139
206
  const [listStale, setListStale] = useState(false);
140
207
  /** How many times status has been retried while none has ever been read. */
141
208
  const [statusRetries, setStatusRetries] = useState(0);
209
+ /** Whether prerelease versions are offered (a preference of this browser). */
210
+ const [allowPrerelease, setAllowPrereleaseState] = useState<boolean>(getAllowPrerelease);
211
+ /** The same, for `refreshUpdates()`, which must stay the same function for the effects that depend on it. */
212
+ const allowPrereleaseRef = useRef(allowPrerelease);
213
+ /** Identifies the latest update check, so a slower earlier one can't replace what it found. */
214
+ const updatesRequest = useRef(0);
215
+ /** The plugins ticked for a bulk action. */
216
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
217
+ /** Set while a bulk action runs. */
218
+ const [bulk, setBulk] = useState<BulkProgress | null>(null);
219
+ const [bulkFailure, setBulkFailure] = useState<BulkFailure | null>(null);
220
+ /** The plugins waiting for the administrator to confirm uninstalling them together. */
221
+ const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState<Plugin[] | null>(null);
222
+ /** How many changes (or the lookups that come before them) are waiting on the server. */
223
+ const [working, setWorking] = useState(0);
142
224
  const statusRequest = useRef<Promise<void> | null>(null);
143
225
  /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
144
226
  const seenPurges = useRef<Map<string, PluginPurgeState>>(new Map());
@@ -169,13 +251,43 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
169
251
  return statusRequest.current;
170
252
  }, []);
171
253
 
172
- const refreshUpdates = useCallback(() => {
254
+ /** Checks the registry for newer versions. Resolves what it found, or `undefined` when it couldn't check. */
255
+ const refreshUpdates = useCallback((): Promise<PluginUpdateInfo[] | undefined> => {
256
+ const token: number = ++updatesRequest.current;
173
257
  // Also advisory: a registry that can't be reached just means no update badges.
174
- return getPluginUpdates()
175
- .then((list) => setUpdates(new Map(list.map((info) => [info.uid, info]))))
176
- .catch(() => setUpdates(new Map()));
258
+ return getPluginUpdates({ prerelease: allowPrereleaseRef.current })
259
+ .then((list): PluginUpdateInfo[] | undefined => {
260
+ if (token === updatesRequest.current) {
261
+ setUpdates(new Map(list.map((info) => [info.uid, info])));
262
+ }
263
+ return list;
264
+ })
265
+ .catch((): PluginUpdateInfo[] | undefined => {
266
+ if (token === updatesRequest.current) {
267
+ setUpdates(new Map());
268
+ }
269
+ return undefined;
270
+ });
271
+ }, []);
272
+
273
+ /** Keeps `work` counted as in progress, which is what the bar across the top of the window shows. */
274
+ const track = useCallback(async <R,>(work: Promise<R>): Promise<R> => {
275
+ setWorking((count) => count + 1);
276
+ try {
277
+ return await work;
278
+ } finally {
279
+ setWorking((count) => count - 1);
280
+ }
177
281
  }, []);
178
282
 
283
+ function changePrerelease(allow: boolean) {
284
+ setAllowPrerelease(allow);
285
+ allowPrereleaseRef.current = allow;
286
+ setAllowPrereleaseState(allow);
287
+ // What counts as an update depends on it.
288
+ void refreshUpdates();
289
+ }
290
+
179
291
  /** Re-reads the list, for changes that also installed or enabled other plugins. */
180
292
  const reload = useCallback(() => {
181
293
  return listPlugins()
@@ -283,12 +395,18 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
283
395
  setBusy(plugin.uid, false);
284
396
  }
285
397
 
398
+ /** Disables an installed plugin, resolving why it couldn't be, or `null`. */
399
+ function disable(plugin: Plugin): Promise<string | null> {
400
+ return attempt(
401
+ async () => applied(await track(updatePlugin(plugin.uid, { version: plugin.version, enabled: false }))),
402
+ `Could not disable ${plugin.manifest.displayName}.`,
403
+ );
404
+ }
405
+
286
406
  function toggle(plugin: Plugin) {
287
407
  const displayName: string = plugin.manifest.displayName;
288
408
  if (plugin.enabled) {
289
- return onRow(plugin, () =>
290
- attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: false })), `Could not disable ${displayName}.`),
291
- );
409
+ return onRow(plugin, () => disable(plugin));
292
410
  }
293
411
  // Enabling a plugin also installs or enables the plugins it requires, so it's previewed like any other change.
294
412
  // The installed version is planned from its stored manifest, so this doesn't need the registry. A preview that
@@ -300,7 +418,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
300
418
  plugin.packageVersion,
301
419
  displayName,
302
420
  async (plan) => {
303
- applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) }));
421
+ applied(await track(updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) })));
304
422
  if (plan.install.length > 0 || plan.enable.length > 0) {
305
423
  void reload();
306
424
  }
@@ -325,11 +443,16 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
325
443
  displayName: string,
326
444
  apply: (plan: PluginChangePlan) => Promise<void>,
327
445
  failure: string,
328
- options: { uid?: string } = {},
446
+ options: {
447
+ uid?: string;
448
+ /** Part of a bulk action, which has no one to confirm the other plugins the change would also install or enable:
449
+ * such a change isn't made, and says why. */
450
+ unattended?: boolean;
451
+ } = {},
329
452
  ): Promise<string | null> {
330
453
  let plan: PluginChangePlan;
331
454
  try {
332
- plan = await planPluginChange(name, packageVersion);
455
+ plan = await track(planPluginChange(name, packageVersion));
333
456
  } catch (err) {
334
457
  return errorMessage(err, failure);
335
458
  }
@@ -337,6 +460,9 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
337
460
  return `${displayName} ${plan.plugin.version} can't be installed. ${plan.conflicts.join(" ")}`;
338
461
  }
339
462
  if (plan.install.length > 0 || plan.enable.length > 0) {
463
+ if (options.unattended) {
464
+ return `${displayName} ${plan.plugin.version} also needs other plugins installed or enabled, so it wasn't changed. Change it on its own to review them.`;
465
+ }
340
466
  setConfirming({ displayName, plan, apply, failure, uid: options.uid });
341
467
  return null;
342
468
  }
@@ -349,7 +475,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
349
475
  packageVersion,
350
476
  displayName,
351
477
  async (plan) => {
352
- const result = await addPlugin(name, packageVersion, expectedPlanOf(plan));
478
+ const result = await track(addPlugin(name, packageVersion, expectedPlanOf(plan)));
353
479
  applied(...result.dependencies, result.plugin);
354
480
  // Adding a plugin cancels the deletion of its data that was waiting for the servers to stop running it.
355
481
  for (const warning of result.warnings ?? []) {
@@ -360,24 +486,24 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
360
486
  );
361
487
  }
362
488
 
363
- function changeVersion(plugin: Plugin, packageVersion: string) {
489
+ function changeVersion(plugin: Plugin, packageVersion: string, unattended = false) {
364
490
  const failure = `Could not upgrade ${plugin.manifest.displayName}.`;
365
491
  if (!plugin.enabled) {
366
492
  // A disabled plugin stays disabled, so nothing it requires is installed or enabled - there's nothing to preview.
367
- return attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion })), failure);
493
+ return attempt(async () => applied(await track(updatePlugin(plugin.uid, { version: plugin.version, packageVersion }))), failure);
368
494
  }
369
495
  return planned(
370
496
  plugin.name,
371
497
  packageVersion,
372
498
  plugin.manifest.displayName,
373
499
  async (plan) => {
374
- applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) }));
500
+ applied(await track(updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) })));
375
501
  if (plan.install.length > 0 || plan.enable.length > 0) {
376
502
  void reload();
377
503
  }
378
504
  },
379
505
  failure,
380
- { uid: plugin.uid },
506
+ { uid: plugin.uid, unattended },
381
507
  );
382
508
  }
383
509
 
@@ -391,7 +517,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
391
517
  return onRow(purge, async () => {
392
518
  let latest: string;
393
519
  try {
394
- latest = (await planPluginChange(purge.name)).plugin.version;
520
+ latest = (await track(planPluginChange(purge.name, undefined, { prerelease: allowPrerelease }))).plugin.version;
395
521
  } catch (err) {
396
522
  return errorMessage(err, `Could not install ${displayName}.`);
397
523
  }
@@ -415,6 +541,130 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
415
541
  setBusy(purge.uid, false);
416
542
  }
417
543
 
544
+ /** Lists a data deletion the server just scheduled. Shown at once; the status polling that follows replaces it with
545
+ * what the servers report. */
546
+ function showScheduledPurge(scheduled: PluginPurgeInfo | undefined) {
547
+ setStatus((prev) =>
548
+ prev && scheduled ? { ...prev, purges: [...(prev.purges ?? []).filter((purge) => purge.uid !== scheduled.uid), scheduled] } : prev,
549
+ );
550
+ }
551
+
552
+ /** Uninstalls an installed plugin, and with `purgeData` deletes the data it stored once no server runs it. Resolves
553
+ * why it couldn't be, or `null`. */
554
+ async function uninstall(plugin: Plugin, purgeData: boolean): Promise<string | null> {
555
+ try {
556
+ const result = await removePlugin(plugin.uid, { purgeData });
557
+ setPlugins((prev) => prev.filter((other) => other.uid !== plugin.uid));
558
+ if (result.purgeScheduled) {
559
+ showScheduledPurge(result.purge);
560
+ }
561
+ return null;
562
+ } catch (err) {
563
+ return isElevationRequired(err) ? ELEVATION_MESSAGE : errorMessage(err, `Could not uninstall ${plugin.manifest.displayName}.`);
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Applies `kind` to `targets` one after another, in the order their requirements allow, and says what became of
569
+ * them. A plugin that can't be changed is left as it was and the rest carry on. For an upgrade, `versions` holds the
570
+ * version each plugin goes to; for an uninstall, `purgeData` also deletes what each stored. Deleting data needs the
571
+ * administrator to have confirmed their identity recently, which no plugin will get past if one is refused for it,
572
+ * so the rest aren't tried.
573
+ */
574
+ async function runBulk(kind: BulkKind, targets: Plugin[], options: { versions?: ReadonlyMap<string, string>; purgeData?: boolean } = {}) {
575
+ const { versions = new Map<string, string>(), purgeData = false } = options;
576
+ const ordered: Plugin[] = orderByRequirements(targets, kind === "upgrade");
577
+ const reasons: string[] = [];
578
+ const failed: Set<string> = new Set();
579
+ setError(null);
580
+ setRetryEnableUid(null);
581
+ setBulkFailure(null);
582
+ await track(
583
+ (async () => {
584
+ for (const [index, plugin] of ordered.entries()) {
585
+ setBulk({ kind, done: index, total: ordered.length, current: plugin.manifest.displayName });
586
+ const problem: string | null =
587
+ kind === "upgrade"
588
+ ? await changeVersion(plugin, versions.get(plugin.uid)!, true)
589
+ : kind === "disable"
590
+ ? await disable(plugin)
591
+ : await uninstall(plugin, purgeData);
592
+ if (!problem) {
593
+ continue;
594
+ }
595
+ failed.add(plugin.uid);
596
+ reasons.push(`${plugin.manifest.displayName}: ${problem}`);
597
+ if (problem === ELEVATION_MESSAGE) {
598
+ for (const untried of ordered.slice(index + 1)) {
599
+ failed.add(untried.uid);
600
+ reasons.push(`${untried.manifest.displayName}: Not attempted.`);
601
+ }
602
+ break;
603
+ }
604
+ }
605
+ })(),
606
+ );
607
+ setBulk(null);
608
+ setSelected(failed);
609
+ const succeeded: number = ordered.length - failed.size;
610
+ if (kind === "uninstall" && succeeded > 0) {
611
+ watchRollout();
612
+ }
613
+ if (succeeded > 0) {
614
+ notify({
615
+ kind: "success",
616
+ title: `${pluralPlugins(succeeded)} ${BULK_WORDS[kind].done}`,
617
+ message: purgeData ? "Their data is deleted once every server has stopped running them. Adding a plugin again before then cancels its deletion." : undefined,
618
+ });
619
+ }
620
+ if (reasons.length > 0) {
621
+ setBulkFailure({ title: `${pluralPlugins(reasons.length)} couldn't be ${BULK_WORDS[kind].done}:`, reasons });
622
+ }
623
+ }
624
+
625
+ /** Upgrades every plugin that has an update, whatever is ticked. Checks the registry first, so it's never working
626
+ * from an out-of-date list of updates. */
627
+ async function upgradeAll() {
628
+ setError(null);
629
+ setBulkFailure(null);
630
+ const found: PluginUpdateInfo[] | undefined = await track(refreshUpdates());
631
+ if (!found) {
632
+ setError("Could not check for plugin updates.");
633
+ return;
634
+ }
635
+ const versions: Map<string, string> = new Map();
636
+ for (const info of found) {
637
+ if (info.updateAvailable && info.latestVersion) {
638
+ versions.set(info.uid, info.latestVersion);
639
+ }
640
+ }
641
+ const targets: Plugin[] = plugins.filter((plugin) => versions.has(plugin.uid));
642
+ if (targets.length === 0) {
643
+ const unchecked: number = found.filter((info) => info.error).length;
644
+ notify(
645
+ unchecked > 0
646
+ ? { kind: "warning", title: "No updates found", message: `${pluralPlugins(unchecked)} couldn't be checked against the registry.` }
647
+ : { kind: "info", title: "All plugins are up to date" },
648
+ );
649
+ return;
650
+ }
651
+ await runBulk("upgrade", targets, { versions });
652
+ }
653
+
654
+ const toggleSelected = (uid: string) =>
655
+ setSelected((prev) => {
656
+ const next = new Set(prev);
657
+ if (!next.delete(uid)) {
658
+ next.add(uid);
659
+ }
660
+ return next;
661
+ });
662
+
663
+ const selectedPlugins: Plugin[] = plugins.filter((plugin) => selected.has(plugin.uid));
664
+ const allSelected: boolean = plugins.length > 0 && selectedPlugins.length === plugins.length;
665
+ const selectedUpgrades: Plugin[] = selectedPlugins.filter((plugin) => updates.get(plugin.uid)?.updateAvailable);
666
+ const selectedEnabled: Plugin[] = selectedPlugins.filter((plugin) => plugin.enabled);
667
+
418
668
  // Retried against the plugin as it's listed now (a newer version, or already enabled elsewhere, hides the retry).
419
669
  const retryEnable: Plugin | undefined = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
420
670
 
@@ -426,9 +676,11 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
426
676
  Add by name
427
677
  </Button>
428
678
  );
679
+ const bulkBusy: boolean = bulk !== null;
429
680
 
430
681
  return (
431
682
  <>
683
+ {(working > 0 || pending) && <PluginProgressBar label={working > 0 ? "Working on plugins" : "Applying plugin changes"} />}
432
684
  {embedded ? (
433
685
  <p className="text-sm text-text-muted mb-5 max-w-3xl">
434
686
  Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run
@@ -448,6 +700,36 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
448
700
  </>
449
701
  )}
450
702
 
703
+ <label className="flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer">
704
+ <input
705
+ type="checkbox"
706
+ className="mt-0.5 h-4 w-4 shrink-0"
707
+ checked={allowPrerelease}
708
+ onChange={(e) => changePrerelease(e.target.checked)}
709
+ />
710
+ <span className="flex flex-col gap-0.5">
711
+ <span className="font-semibold">Allow pre-release versions</span>
712
+ <span className="text-xs text-text-muted">
713
+ Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to
714
+ install. They can be unstable. This is remembered in this browser.
715
+ </span>
716
+ </span>
717
+ </label>
718
+
719
+ {bulkFailure && (
720
+ <Alert>
721
+ <div>
722
+ {bulkFailure.title}
723
+ <ul className="list-disc pl-5 mt-1">
724
+ {bulkFailure.reasons.map((reason) => (
725
+ <li key={reason} className="break-words">
726
+ {reason}
727
+ </li>
728
+ ))}
729
+ </ul>
730
+ </div>
731
+ </Alert>
732
+ )}
451
733
  {error && (
452
734
  <Alert>
453
735
  {error}
@@ -489,6 +771,75 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
489
771
  <p className="text-sm text-text-muted">No plugins installed.</p>
490
772
  ) : (
491
773
  <div className="overflow-x-auto">
774
+ {plugins.length > 0 && (
775
+ <div role="toolbar" aria-label="Actions for several plugins" className="flex flex-wrap items-center gap-2 py-2">
776
+ <label className="flex items-center gap-2 text-sm mr-2 cursor-pointer">
777
+ <input
778
+ type="checkbox"
779
+ className="h-4 w-4 shrink-0"
780
+ aria-label="Select all plugins"
781
+ checked={allSelected}
782
+ disabled={bulkBusy}
783
+ ref={(input) => {
784
+ if (input) {
785
+ input.indeterminate = selectedPlugins.length > 0 && !allSelected;
786
+ }
787
+ }}
788
+ onChange={(e) => setSelected(e.target.checked ? new Set(plugins.map((plugin) => plugin.uid)) : new Set())}
789
+ />
790
+ <span>{selectedPlugins.length > 0 ? `${selectedPlugins.length} selected` : "Select all"}</span>
791
+ </label>
792
+ <Button
793
+ type="button"
794
+ variant="secondary"
795
+ className="!w-auto"
796
+ aria-label="Upgrade selected plugins"
797
+ disabled={bulkBusy || selectedUpgrades.length === 0}
798
+ onClick={() =>
799
+ void runBulk("upgrade", selectedUpgrades, {
800
+ versions: new Map(selectedUpgrades.map((plugin) => [plugin.uid, updates.get(plugin.uid)!.latestVersion!])),
801
+ })
802
+ }
803
+ >
804
+ Upgrade{selectedUpgrades.length > 0 ? ` (${selectedUpgrades.length})` : ""}
805
+ </Button>
806
+ <Button
807
+ type="button"
808
+ variant="secondary"
809
+ className="!w-auto"
810
+ aria-label="Disable selected plugins"
811
+ disabled={bulkBusy || selectedEnabled.length === 0}
812
+ onClick={() => void runBulk("disable", selectedEnabled)}
813
+ >
814
+ Disable{selectedEnabled.length > 0 ? ` (${selectedEnabled.length})` : ""}
815
+ </Button>
816
+ <Button
817
+ type="button"
818
+ variant="secondary"
819
+ className="!w-auto"
820
+ aria-label="Uninstall selected plugins"
821
+ disabled={bulkBusy || selectedPlugins.length === 0}
822
+ onClick={() => setConfirmingBulkUninstall(selectedPlugins)}
823
+ >
824
+ Uninstall{selectedPlugins.length > 0 ? ` (${selectedPlugins.length})` : ""}
825
+ </Button>
826
+ <Button
827
+ type="button"
828
+ className="!w-auto ml-auto"
829
+ aria-label="Upgrade all plugins"
830
+ loading={bulk?.kind === "upgrade"}
831
+ disabled={bulkBusy}
832
+ onClick={() => void upgradeAll()}
833
+ >
834
+ Upgrade all
835
+ </Button>
836
+ {bulk && (
837
+ <span aria-live="polite" className="basis-full text-xs text-text-muted">
838
+ {BULK_WORDS[bulk.kind].doing} {bulk.current} ({bulk.done + 1} of {bulk.total})&hellip;
839
+ </span>
840
+ )}
841
+ </div>
842
+ )}
492
843
  <table role="table" className="w-full text-sm border-collapse block lg:table">
493
844
  <thead role="rowgroup" className="hidden lg:table-header-group">
494
845
  <tr role="row">
@@ -510,7 +861,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
510
861
  {plugins.map((plugin) => {
511
862
  const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
512
863
  const latest: string | undefined = update?.updateAvailable ? update.latestVersion : undefined;
513
- const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid;
864
+ const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
514
865
  const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
515
866
  const requiredBy: string[] = plugins
516
867
  .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
@@ -518,15 +869,27 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
518
869
  return (
519
870
  <tr key={plugin.uid} role="row" className={INSTALLED_ROW_CLASS}>
520
871
  <td role="cell" className={`${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`}>
521
- <div className="font-semibold">{plugin.manifest.displayName}</div>
522
- <div className="text-xs text-text-muted">{plugin.name}</div>
523
- {plugin.manifest.description && (
524
- <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
525
- )}
526
- {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
527
- {requiredBy.length > 0 && (
528
- <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
529
- )}
872
+ <div className="flex items-start gap-3">
873
+ <input
874
+ type="checkbox"
875
+ className="mt-1 h-4 w-4 shrink-0"
876
+ aria-label={`Select ${plugin.manifest.displayName}`}
877
+ checked={selected.has(plugin.uid)}
878
+ disabled={bulkBusy}
879
+ onChange={() => toggleSelected(plugin.uid)}
880
+ />
881
+ <div className="min-w-0">
882
+ <div className="font-semibold">{plugin.manifest.displayName}</div>
883
+ <div className="text-xs text-text-muted">{plugin.name}</div>
884
+ {plugin.manifest.description && (
885
+ <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
886
+ )}
887
+ {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
888
+ {requiredBy.length > 0 && (
889
+ <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
890
+ )}
891
+ </div>
892
+ </div>
530
893
  </td>
531
894
  <td role="cell" className={`${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`}>
532
895
  <div>{plugin.packageVersion}</div>
@@ -596,6 +959,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
596
959
  <PluginBrowser
597
960
  headingLevel={SectionHeading}
598
961
  plugins={plugins}
962
+ prerelease={allowPrerelease}
599
963
  onInstall={(result) => install(result.name, result.version, result.name)}
600
964
  // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
601
965
  onUpgrade={(uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid)!, packageVersion)}
@@ -603,6 +967,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
603
967
 
604
968
  <AddPluginModal
605
969
  open={adding}
970
+ prerelease={allowPrerelease}
606
971
  onClose={() => setAdding(false)}
607
972
  onAdd={async (name, packageVersion, displayName) => {
608
973
  const problem = await install(name, packageVersion, displayName);
@@ -615,6 +980,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
615
980
  {upgrading && (
616
981
  <ChangeVersionModal
617
982
  plugin={upgrading}
983
+ prerelease={allowPrerelease}
618
984
  onClose={() => setUpgrading(null)}
619
985
  onSave={async (packageVersion) => {
620
986
  const problem = await changeVersion(upgrading, packageVersion);
@@ -633,6 +999,17 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
633
999
  onDone={() => setConfirming(null)}
634
1000
  />
635
1001
  )}
1002
+ {confirmingBulkUninstall && (
1003
+ <BulkUninstallModal
1004
+ plugins={confirmingBulkUninstall}
1005
+ onClose={() => setConfirmingBulkUninstall(null)}
1006
+ onConfirm={(purgeData) => {
1007
+ const targets = confirmingBulkUninstall;
1008
+ setConfirmingBulkUninstall(null);
1009
+ void runBulk("uninstall", targets, { purgeData });
1010
+ }}
1011
+ />
1012
+ )}
636
1013
  {configuring && (
637
1014
  <SettingsModal
638
1015
  plugin={configuring}
@@ -653,11 +1030,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
653
1030
  setRemoving(null);
654
1031
  setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
655
1032
  if (result.purgeScheduled) {
656
- // Shown at once; the status polling that follows replaces it with what the servers report.
657
- const scheduled = result.purge;
658
- setStatus((prev) =>
659
- prev && scheduled ? { ...prev, purges: [...(prev.purges ?? []).filter((purge) => purge.uid !== scheduled.uid), scheduled] } : prev,
660
- );
1033
+ showScheduledPurge(result.purge);
661
1034
  notify({
662
1035
  kind: "info",
663
1036
  title: `${displayName} uninstalled`,
@@ -678,11 +1051,14 @@ const ALL_NAMESPACES = "";
678
1051
  function PluginBrowser({
679
1052
  headingLevel: Heading,
680
1053
  plugins,
1054
+ prerelease,
681
1055
  onInstall,
682
1056
  onUpgrade,
683
1057
  }: {
684
1058
  headingLevel: "h2" | "h3";
685
1059
  plugins: Plugin[];
1060
+ /** Whether prerelease versions count as a plugin's newest. */
1061
+ prerelease: boolean;
686
1062
  /** Resolves why the plugin couldn't be installed, or `null`. */
687
1063
  onInstall: (result: PluginSearchResult) => Promise<string | null>;
688
1064
  onUpgrade: (uid: string, packageVersion: string) => Promise<void>;
@@ -700,12 +1076,15 @@ function PluginBrowser({
700
1076
  .catch(() => setNamespaces([]));
701
1077
  }, []);
702
1078
 
1079
+ // What was found was found under the other setting, so it isn't shown as though it still held.
1080
+ useEffect(() => setResults(null), [prerelease]);
1081
+
703
1082
  async function search(e: FormEvent) {
704
1083
  e.preventDefault();
705
1084
  setSearching(true);
706
1085
  setError(null);
707
1086
  try {
708
- setResults(await searchPlugins(namespace || undefined));
1087
+ setResults(await searchPlugins(namespace || undefined, { prerelease }));
709
1088
  } catch (err) {
710
1089
  setResults(null);
711
1090
  setError(errorMessage(err, "Could not search for plugins."));
@@ -996,10 +1375,13 @@ function UninstalledPluginRow({ purge, busy, onRetry, onInstall }: { purge: Plug
996
1375
 
997
1376
  function AddPluginModal({
998
1377
  open,
1378
+ prerelease,
999
1379
  onClose,
1000
1380
  onAdd,
1001
1381
  }: {
1002
1382
  open: boolean;
1383
+ /** Whether prerelease versions are listed, and count as the package's newest. */
1384
+ prerelease: boolean;
1003
1385
  onClose: () => void;
1004
1386
  /** Resolves why the plugin couldn't be added, or `null`. */
1005
1387
  onAdd: (name: string, packageVersion: string, displayName: string) => Promise<string | null>;
@@ -1040,7 +1422,7 @@ function AddPluginModal({
1040
1422
  setLooking(true);
1041
1423
  setError(null);
1042
1424
  try {
1043
- const found = await lookupPluginPackage(packageName, packageVersion);
1425
+ const found = await lookupPluginPackage(packageName, packageVersion, { prerelease });
1044
1426
  if (token === lookupToken.current) {
1045
1427
  setLookup(found);
1046
1428
  setSelectedVersion(found.selected.version);
@@ -1161,10 +1543,13 @@ function AddPluginModal({
1161
1543
 
1162
1544
  function ChangeVersionModal({
1163
1545
  plugin,
1546
+ prerelease,
1164
1547
  onClose,
1165
1548
  onSave,
1166
1549
  }: {
1167
1550
  plugin: Plugin;
1551
+ /** Whether prerelease versions are listed, and count as the package's newest. */
1552
+ prerelease: boolean;
1168
1553
  onClose: () => void;
1169
1554
  /** Resolves why the version couldn't be changed, or `null`. */
1170
1555
  onSave: (packageVersion: string) => Promise<string | null>;
@@ -1176,13 +1561,13 @@ function ChangeVersionModal({
1176
1561
  const [busy, setBusy] = useState(false);
1177
1562
 
1178
1563
  useEffect(() => {
1179
- lookupPluginPackage(plugin.name)
1564
+ lookupPluginPackage(plugin.name, undefined, { prerelease })
1180
1565
  .then((found) => {
1181
1566
  setVersions(found.package.versions);
1182
1567
  setLatest(found.package.latest);
1183
1568
  })
1184
1569
  .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
1185
- }, [plugin.name]);
1570
+ }, [plugin.name, prerelease]);
1186
1571
 
1187
1572
  async function save() {
1188
1573
  setBusy(true);
@@ -1530,6 +1915,94 @@ function SettingField({
1530
1915
  );
1531
1916
  }
1532
1917
 
1918
+ /** The word typed to confirm deleting the data of several plugins at once, in place of a plugin's name. */
1919
+ const BULK_DELETE_WORD = "delete";
1920
+
1921
+ /** Asks for confirmation before several plugins are uninstalled at once, listing them, with the choice to delete the
1922
+ * data they stored too. Deleting data is permanent, so it also takes `BULK_DELETE_WORD` typed out. */
1923
+ function BulkUninstallModal({ plugins, onClose, onConfirm }: { plugins: Plugin[]; onClose: () => void; onConfirm: (purgeData: boolean) => void }) {
1924
+ const [purgeData, setPurgeData] = useState(false);
1925
+ const [typed, setTyped] = useState("");
1926
+ const helpId = useId();
1927
+ const single: boolean = plugins.length === 1;
1928
+ const confirmed: boolean = !purgeData || typed.trim().toLowerCase() === BULK_DELETE_WORD;
1929
+
1930
+ return (
1931
+ <Modal open onClose={onClose} title={`Uninstall ${pluralPlugins(plugins.length)}?`}>
1932
+ <form
1933
+ onSubmit={(event) => {
1934
+ event.preventDefault();
1935
+ if (confirmed) {
1936
+ onConfirm(purgeData);
1937
+ }
1938
+ }}
1939
+ >
1940
+ <p className="text-sm mb-2">The servers stop running {single ? "this plugin" : "these plugins"} after they restart:</p>
1941
+ <ul className="text-sm list-disc pl-5 mb-4">
1942
+ {plugins.map((plugin) => (
1943
+ <li key={plugin.uid}>{plugin.manifest.displayName}</li>
1944
+ ))}
1945
+ </ul>
1946
+ <p className="text-sm mb-4">
1947
+ {purgeData
1948
+ ? `Once every server has stopped running ${single ? "it" : "them"}, all the data ${single ? "it" : "they"} stored is deleted.`
1949
+ : `Data ${single ? "it" : "they"} stored stays in the database, and adding ${single ? "the plugin" : "a plugin"} again brings it back.`}
1950
+ </p>
1951
+ <div className="mb-4">
1952
+ <label className="flex items-start gap-2 text-sm font-semibold cursor-pointer">
1953
+ <input
1954
+ type="checkbox"
1955
+ className="mt-0.5 h-4 w-4 shrink-0 accent-danger"
1956
+ checked={purgeData}
1957
+ aria-describedby={helpId}
1958
+ onChange={(e) => setPurgeData(e.target.checked)}
1959
+ />
1960
+ <span>Also delete all data {single ? "this plugin" : "these plugins"} stored</span>
1961
+ </label>
1962
+ <div id={helpId} className="mt-2 pl-6 text-xs text-text-muted">
1963
+ <p>What is deleted, for each plugin:</p>
1964
+ <ul className="list-disc pl-5 mt-1 space-y-0.5">
1965
+ <li>the database collections and tables its features use, with everything in them</li>
1966
+ <li>its saved settings</li>
1967
+ <li>its downloaded package and cached pages on the servers</li>
1968
+ <li>whatever else it cleans up itself, such as files it stored or data kept outside the database</li>
1969
+ </ul>
1970
+ <p className="mt-2 text-danger font-semibold">This can&apos;t be undone.</p>
1971
+ </div>
1972
+ </div>
1973
+ {purgeData && (
1974
+ <>
1975
+ <p className="text-xs text-text-muted mb-3">
1976
+ Nothing is deleted while a server is still running a plugin. Adding a plugin again before its data is deleted cancels the deletion.
1977
+ </p>
1978
+ <label className="block text-sm mb-4">
1979
+ <span>
1980
+ Type <strong>{BULK_DELETE_WORD}</strong> to confirm
1981
+ </span>
1982
+ <input
1983
+ type="text"
1984
+ className={`${INPUT_CLASS} mt-1.5`}
1985
+ value={typed}
1986
+ autoComplete="off"
1987
+ spellCheck={false}
1988
+ onChange={(e) => setTyped(e.target.value)}
1989
+ />
1990
+ </label>
1991
+ </>
1992
+ )}
1993
+ <div className="flex gap-2 justify-end">
1994
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1995
+ Cancel
1996
+ </Button>
1997
+ <Button type="submit" className={purgeData ? DANGER_BUTTON_CLASS : "!w-auto"} disabled={!confirmed}>
1998
+ {purgeData ? `Uninstall ${pluralPlugins(plugins.length)} and delete data` : `Uninstall ${pluralPlugins(plugins.length)}`}
1999
+ </Button>
2000
+ </div>
2001
+ </form>
2002
+ </Modal>
2003
+ );
2004
+ }
2005
+
1533
2006
  function RemoveModal({ plugin, onClose, onRemoved }: { plugin: Plugin; onClose: () => void; onRemoved: (result: RemovePluginResult) => void }) {
1534
2007
  const [error, setError] = useState<string | null>(null);
1535
2008
  const [busy, setBusy] = useState(false);