@rapidmx/web-client 0.31.0 → 0.32.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/apps/shared/components/admin/diagnostics/InformationPanel.tsx +2 -2
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +2 -2
- package/apps/shared/components/admin/settings/PluginsManager.tsx +156 -29
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +1 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +79 -8
- package/dist/lib/admin/pluginsApi.d.ts +18 -0
- package/dist/lib/admin/pluginsApi.js +15 -1
- package/lib/admin/pluginsApi.ts +24 -1
- package/package.json +1 -1
|
@@ -41,13 +41,13 @@ export default function InformationPanel({ versions, plugins, information }: Inf
|
|
|
41
41
|
<SettingsTable
|
|
42
42
|
id="diagnostics-environment"
|
|
43
43
|
title="Environment variables"
|
|
44
|
-
description="The server process's environment.
|
|
44
|
+
description="The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent."
|
|
45
45
|
settings={information.data.environment ?? []}
|
|
46
46
|
/>
|
|
47
47
|
<SettingsTable
|
|
48
48
|
id="diagnostics-configuration"
|
|
49
49
|
title="Configuration"
|
|
50
|
-
description="The effective settings the server runs with: the environment, saved plugin settings and defaults combined.
|
|
50
|
+
description="The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs."
|
|
51
51
|
settings={information.data.configuration ?? []}
|
|
52
52
|
/>
|
|
53
53
|
</>
|
|
@@ -76,8 +76,8 @@ export interface DiagnosticsVersions {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every
|
|
80
|
-
*
|
|
79
|
+
* One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds the value of every setting on its
|
|
80
|
+
* hidden list and never sends it, so there is nothing for the client to hide.
|
|
81
81
|
*/
|
|
82
82
|
export interface DiagnosticsSetting {
|
|
83
83
|
name: string;
|
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
retryPluginPurge,
|
|
34
34
|
searchPlugins,
|
|
35
35
|
updatePlugin,
|
|
36
|
+
uploadPlugin,
|
|
36
37
|
} from "../../../../../lib/admin/pluginsApi.js";
|
|
38
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
37
39
|
import Alert from "../ActionAlert.js";
|
|
38
40
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
39
41
|
import Modal from "../../../../../lib/components/overlays/Modal.js";
|
|
@@ -84,6 +86,21 @@ function purgeFailure(purge: PluginPurgeInfo): string {
|
|
|
84
86
|
/** A day, in the administrator's own format. */
|
|
85
87
|
const formatDate = (iso: string): string => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
|
86
88
|
|
|
89
|
+
/** The file names an uploaded plugin may have: what `npm pack` produces (`<name>-<version>.tgz`), or the same gzipped. */
|
|
90
|
+
const PLUGIN_PACK_NAME = /\.(tgz|tar\.gz|gz)$/i;
|
|
91
|
+
|
|
92
|
+
/** What the server answers (409) an upload of a plugin that is already installed, which the administrator may replace. */
|
|
93
|
+
const ALREADY_INSTALLED = /already installed/i;
|
|
94
|
+
|
|
95
|
+
/** An upload the administrator is being asked to let replace the plugin of the same name. */
|
|
96
|
+
interface PendingReplace {
|
|
97
|
+
file: File;
|
|
98
|
+
/** What the replacement would do, as a question. */
|
|
99
|
+
question: string;
|
|
100
|
+
/** Why the server refused, in its words. */
|
|
101
|
+
reason: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
/** A change waiting for the administrator to confirm the other plugins it also installs or enables. */
|
|
88
105
|
interface PendingChange {
|
|
89
106
|
displayName: string;
|
|
@@ -225,6 +242,11 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
225
242
|
const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState<Plugin[] | null>(null);
|
|
226
243
|
/** How many changes (or the lookups that come before them) are waiting on the server. */
|
|
227
244
|
const [working, setWorking] = useState(0);
|
|
245
|
+
/** Set while an uploaded plugin is being sent to the server. */
|
|
246
|
+
const [uploading, setUploading] = useState(false);
|
|
247
|
+
/** An upload that would replace an installed plugin, waiting for the administrator to confirm it. */
|
|
248
|
+
const [replacing, setReplacing] = useState<PendingReplace | null>(null);
|
|
249
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
228
250
|
const statusRequest = useRef<Promise<void> | null>(null);
|
|
229
251
|
/** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
|
|
230
252
|
const seenPurges = useRef<Map<string, PluginPurgeState>>(new Map());
|
|
@@ -517,6 +539,54 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
517
539
|
return onRow(plugin, () => changeVersion(plugin, packageVersion));
|
|
518
540
|
}
|
|
519
541
|
|
|
542
|
+
/** Sends `file` to the server as a plugin to install - replacing the installed plugin of that name only when `replace` is set. A plugin that is
|
|
543
|
+
* already installed isn't an error but a question, asked of the administrator before it is replaced. */
|
|
544
|
+
async function runUpload(file: File, replace: boolean) {
|
|
545
|
+
setUploading(true);
|
|
546
|
+
setError(null);
|
|
547
|
+
setRetryEnableUid(null);
|
|
548
|
+
try {
|
|
549
|
+
const uploaded: Plugin = await track(uploadPlugin(file, { replace }));
|
|
550
|
+
applied(uploaded);
|
|
551
|
+
notify({
|
|
552
|
+
kind: "success",
|
|
553
|
+
title: `${uploaded.name}@${uploaded.packageVersion} uploaded`,
|
|
554
|
+
message: "The servers restart one at a time to load it.",
|
|
555
|
+
});
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (!replace && err instanceof ApiRequestError && err.status === 409 && ALREADY_INSTALLED.test(err.message)) {
|
|
558
|
+
const installed: Plugin | undefined = plugins.find((plugin) => err.message.includes(plugin.name));
|
|
559
|
+
setReplacing({
|
|
560
|
+
file,
|
|
561
|
+
question: installed
|
|
562
|
+
? `Replace ${installed.name} ${installed.packageVersion} with the uploaded pack?`
|
|
563
|
+
: "Replace the installed plugin with the uploaded pack?",
|
|
564
|
+
reason: err.message,
|
|
565
|
+
});
|
|
566
|
+
} else if (err instanceof ApiRequestError && err.status === 413) {
|
|
567
|
+
setError("That file is too large for the server to accept.");
|
|
568
|
+
} else {
|
|
569
|
+
setError(errorMessage(err, "Could not upload the plugin."));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
setUploading(false);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Takes the file the administrator chose, unless it can't be what `npm pack` produces. */
|
|
576
|
+
function onFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
|
|
577
|
+
const file: File | undefined = e.target.files?.[0];
|
|
578
|
+
// So choosing the same file again (after fixing what was wrong) still counts as a change.
|
|
579
|
+
e.target.value = "";
|
|
580
|
+
if (!file) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (!PLUGIN_PACK_NAME.test(file.name)) {
|
|
584
|
+
setError(`${file.name} isn't a plugin pack. Choose the .tgz file that npm pack produces.`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
void runUpload(file, false);
|
|
588
|
+
}
|
|
589
|
+
|
|
520
590
|
/** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
|
|
521
591
|
function reinstall(purge: PluginPurgeInfo) {
|
|
522
592
|
const displayName: string = purgeName(purge);
|
|
@@ -682,12 +752,34 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
682
752
|
const displayNameOf = (name: string): string => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
|
|
683
753
|
|
|
684
754
|
const SectionHeading = embedded ? "h3" : "h2";
|
|
755
|
+
const bulkBusy: boolean = bulk !== null;
|
|
685
756
|
const addButton = (
|
|
686
|
-
<
|
|
687
|
-
|
|
688
|
-
|
|
757
|
+
<div className="flex flex-wrap gap-2 shrink-0">
|
|
758
|
+
<input
|
|
759
|
+
ref={fileInput}
|
|
760
|
+
type="file"
|
|
761
|
+
className="hidden"
|
|
762
|
+
accept=".tgz,.tar.gz,application/gzip,application/x-gzip"
|
|
763
|
+
aria-label="Plugin pack file"
|
|
764
|
+
tabIndex={-1}
|
|
765
|
+
onChange={onFileChosen}
|
|
766
|
+
/>
|
|
767
|
+
<Button
|
|
768
|
+
type="button"
|
|
769
|
+
variant="secondary"
|
|
770
|
+
className="!w-auto"
|
|
771
|
+
title="Install a plugin from the .tgz file that npm pack produces"
|
|
772
|
+
loading={uploading}
|
|
773
|
+
disabled={uploading || bulkBusy}
|
|
774
|
+
onClick={() => fileInput.current?.click()}
|
|
775
|
+
>
|
|
776
|
+
{uploading ? "Uploading\u2026" : "Upload"}
|
|
777
|
+
</Button>
|
|
778
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setAdding(true)}>
|
|
779
|
+
Add by name
|
|
780
|
+
</Button>
|
|
781
|
+
</div>
|
|
689
782
|
);
|
|
690
|
-
const bulkBusy: boolean = bulk !== null;
|
|
691
783
|
|
|
692
784
|
return (
|
|
693
785
|
<>
|
|
@@ -741,24 +833,18 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
741
833
|
</div>
|
|
742
834
|
</Alert>
|
|
743
835
|
)}
|
|
744
|
-
{
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
Try again
|
|
757
|
-
</Button>
|
|
758
|
-
</>
|
|
759
|
-
)}
|
|
760
|
-
</Alert>
|
|
761
|
-
)}
|
|
836
|
+
{/* A message on its own is a plain string child, which is what lets the alert offer to confirm the identity again. */}
|
|
837
|
+
{error &&
|
|
838
|
+
(retryEnable ? (
|
|
839
|
+
<Alert>
|
|
840
|
+
{error}{" "}
|
|
841
|
+
<Button type="button" variant="text" className="!w-auto !p-0" onClick={() => void toggle(retryEnable)}>
|
|
842
|
+
Try again
|
|
843
|
+
</Button>
|
|
844
|
+
</Alert>
|
|
845
|
+
) : (
|
|
846
|
+
<Alert>{error}</Alert>
|
|
847
|
+
))}
|
|
762
848
|
<RolloutBanner status={status} />
|
|
763
849
|
{statusStale && status && (
|
|
764
850
|
<p className="mb-4 text-xs text-text-muted">Couldn't refresh server status. Showing the last status reported.</p>
|
|
@@ -871,7 +957,9 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
871
957
|
<tbody role="rowgroup" className="block lg:table-row-group">
|
|
872
958
|
{plugins.map((plugin) => {
|
|
873
959
|
const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
|
|
874
|
-
const
|
|
960
|
+
const uploaded: boolean = plugin.source === "upload";
|
|
961
|
+
// An uploaded pack has no registry version to be newer than.
|
|
962
|
+
const latest: string | undefined = !uploaded && update?.updateAvailable ? update.latestVersion : undefined;
|
|
875
963
|
const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
|
|
876
964
|
const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
|
|
877
965
|
const requiredBy: string[] = plugins
|
|
@@ -895,6 +983,13 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
895
983
|
{plugin.manifest.description && (
|
|
896
984
|
<div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
|
|
897
985
|
)}
|
|
986
|
+
{uploaded && (
|
|
987
|
+
<div className="text-xs text-text-muted mt-1 break-words">
|
|
988
|
+
<span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Uploaded</span>{" "}
|
|
989
|
+
{plugin.uploadFilename}
|
|
990
|
+
{plugin.uploadedAt ? `, ${formatDate(plugin.uploadedAt)}` : ""}
|
|
991
|
+
</div>
|
|
992
|
+
)}
|
|
898
993
|
{requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
|
|
899
994
|
{requiredBy.length > 0 && (
|
|
900
995
|
<div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
|
|
@@ -942,7 +1037,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
942
1037
|
</div>
|
|
943
1038
|
<div className="flex flex-wrap gap-x-4 lg:justify-end">
|
|
944
1039
|
<Button variant="text" type="button" onClick={() => setUpgrading(plugin)}>
|
|
945
|
-
Change version
|
|
1040
|
+
{uploaded ? "Install the registry version" : "Change version"}
|
|
946
1041
|
</Button>
|
|
947
1042
|
<Button variant="text" type="button" onClick={() => setRemoving(plugin)}>
|
|
948
1043
|
Uninstall
|
|
@@ -1002,6 +1097,28 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
|
|
|
1002
1097
|
}}
|
|
1003
1098
|
/>
|
|
1004
1099
|
)}
|
|
1100
|
+
{replacing && (
|
|
1101
|
+
<Modal open onClose={() => setReplacing(null)} title="Replace plugin?">
|
|
1102
|
+
<p className="text-sm mb-2">{replacing.question}</p>
|
|
1103
|
+
<p className="text-sm text-text-muted mb-4 break-words">{replacing.reason}</p>
|
|
1104
|
+
<div className="flex gap-2 justify-end">
|
|
1105
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setReplacing(null)}>
|
|
1106
|
+
Cancel
|
|
1107
|
+
</Button>
|
|
1108
|
+
<Button
|
|
1109
|
+
type="button"
|
|
1110
|
+
className="!w-auto"
|
|
1111
|
+
onClick={() => {
|
|
1112
|
+
const { file } = replacing;
|
|
1113
|
+
setReplacing(null);
|
|
1114
|
+
void runUpload(file, true);
|
|
1115
|
+
}}
|
|
1116
|
+
>
|
|
1117
|
+
Replace
|
|
1118
|
+
</Button>
|
|
1119
|
+
</div>
|
|
1120
|
+
</Modal>
|
|
1121
|
+
)}
|
|
1005
1122
|
{confirming && (
|
|
1006
1123
|
<ConfirmDependenciesModal
|
|
1007
1124
|
change={confirming}
|
|
@@ -1570,15 +1687,20 @@ function ChangeVersionModal({
|
|
|
1570
1687
|
const [selected, setSelected] = useState(plugin.packageVersion);
|
|
1571
1688
|
const [error, setError] = useState<string | null>(null);
|
|
1572
1689
|
const [busy, setBusy] = useState(false);
|
|
1690
|
+
// An uploaded pack isn't a registry version, so any registry version is a change from it - the newest is offered first.
|
|
1691
|
+
const uploaded: boolean = plugin.source === "upload";
|
|
1573
1692
|
|
|
1574
1693
|
useEffect(() => {
|
|
1575
1694
|
lookupPluginPackage(plugin.name, undefined, { prerelease })
|
|
1576
1695
|
.then((found) => {
|
|
1577
1696
|
setVersions(found.package.versions);
|
|
1578
1697
|
setLatest(found.package.latest);
|
|
1698
|
+
if (uploaded && found.package.latest) {
|
|
1699
|
+
setSelected(found.package.latest);
|
|
1700
|
+
}
|
|
1579
1701
|
})
|
|
1580
1702
|
.catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
|
|
1581
|
-
}, [plugin.name, prerelease]);
|
|
1703
|
+
}, [plugin.name, prerelease, uploaded]);
|
|
1582
1704
|
|
|
1583
1705
|
async function save() {
|
|
1584
1706
|
setBusy(true);
|
|
@@ -1591,12 +1713,17 @@ function ChangeVersionModal({
|
|
|
1591
1713
|
}
|
|
1592
1714
|
|
|
1593
1715
|
return (
|
|
1594
|
-
<Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
|
|
1716
|
+
<Modal open onClose={onClose} title={uploaded ? `Install the registry version of ${plugin.manifest.displayName}` : `${plugin.manifest.displayName} version`}>
|
|
1595
1717
|
{error && <Alert>{error}</Alert>}
|
|
1596
1718
|
{versions === null ? (
|
|
1597
1719
|
!error && <p className="text-sm text-text-muted">Loading…</p>
|
|
1598
1720
|
) : (
|
|
1599
1721
|
<div className="flex flex-col gap-3">
|
|
1722
|
+
{uploaded && (
|
|
1723
|
+
<p className="text-sm text-text-muted">
|
|
1724
|
+
{plugin.manifest.displayName} {plugin.packageVersion} was uploaded as {plugin.uploadFilename}. This replaces it with the version from the registry.
|
|
1725
|
+
</p>
|
|
1726
|
+
)}
|
|
1600
1727
|
<label className="flex flex-col gap-1.5 text-sm">
|
|
1601
1728
|
<span className="font-semibold">Version</span>
|
|
1602
1729
|
<select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
|
|
@@ -1604,7 +1731,7 @@ function ChangeVersionModal({
|
|
|
1604
1731
|
<option key={version} value={version}>
|
|
1605
1732
|
{version}
|
|
1606
1733
|
{version === latest ? " (latest)" : ""}
|
|
1607
|
-
{version === plugin.packageVersion ? " (installed)" : ""}
|
|
1734
|
+
{!uploaded && version === plugin.packageVersion ? " (installed)" : ""}
|
|
1608
1735
|
</option>
|
|
1609
1736
|
))}
|
|
1610
1737
|
</select>
|
|
@@ -1617,10 +1744,10 @@ function ChangeVersionModal({
|
|
|
1617
1744
|
type="button"
|
|
1618
1745
|
className="!w-auto"
|
|
1619
1746
|
loading={busy}
|
|
1620
|
-
disabled={busy || selected === plugin.packageVersion}
|
|
1747
|
+
disabled={busy || (!uploaded && selected === plugin.packageVersion)}
|
|
1621
1748
|
onClick={() => void save()}
|
|
1622
1749
|
>
|
|
1623
|
-
Save
|
|
1750
|
+
{uploaded ? "Install" : "Save"}
|
|
1624
1751
|
</Button>
|
|
1625
1752
|
</div>
|
|
1626
1753
|
</div>
|
|
@@ -12,5 +12,5 @@ import SettingsTable from "./SettingsTable.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export default function InformationPanel({ versions, plugins, information }) {
|
|
14
14
|
const { data, error, loading } = versions;
|
|
15
|
-
return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment.
|
|
15
|
+
return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent.", settings: information.data.environment ?? [] }), _jsx(SettingsTable, { id: "diagnostics-configuration", title: "Configuration", description: "The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs.", settings: information.data.configuration ?? [] })] })), _jsx(PackagesTable, { packages: data.packages ?? [] })] }))] }));
|
|
16
16
|
}
|
|
@@ -63,8 +63,8 @@ export interface DiagnosticsVersions {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
* One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every
|
|
67
|
-
*
|
|
66
|
+
* One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds the value of every setting on its
|
|
67
|
+
* hidden list and never sends it, so there is nothing for the client to hide.
|
|
68
68
|
*/
|
|
69
69
|
export interface DiagnosticsSetting {
|
|
70
70
|
name: string;
|
|
@@ -4,7 +4,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
7
|
-
import { addPlugin, expectedPlanOf, getPluginStatus, getPluginUpdates, isSecretSetting, isSecretSettingKey, listPluginNamespaces, listPlugins, lookupPluginPackage, planPluginChange, removePlugin, retryPluginPurge, searchPlugins, updatePlugin, } from "../../../../../lib/admin/pluginsApi.js";
|
|
7
|
+
import { addPlugin, expectedPlanOf, getPluginStatus, getPluginUpdates, isSecretSetting, isSecretSettingKey, listPluginNamespaces, listPlugins, lookupPluginPackage, planPluginChange, removePlugin, retryPluginPurge, searchPlugins, updatePlugin, uploadPlugin, } from "../../../../../lib/admin/pluginsApi.js";
|
|
8
|
+
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
8
9
|
import Alert from "../ActionAlert.js";
|
|
9
10
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
10
11
|
import Modal from "../../../../../lib/components/overlays/Modal.js";
|
|
@@ -43,6 +44,10 @@ function purgeFailure(purge) {
|
|
|
43
44
|
}
|
|
44
45
|
/** A day, in the administrator's own format. */
|
|
45
46
|
const formatDate = (iso) => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
|
47
|
+
/** The file names an uploaded plugin may have: what `npm pack` produces (`<name>-<version>.tgz`), or the same gzipped. */
|
|
48
|
+
const PLUGIN_PACK_NAME = /\.(tgz|tar\.gz|gz)$/i;
|
|
49
|
+
/** What the server answers (409) an upload of a plugin that is already installed, which the administrator may replace. */
|
|
50
|
+
const ALREADY_INSTALLED = /already installed/i;
|
|
46
51
|
const BULK_WORDS = {
|
|
47
52
|
upgrade: { doing: "Upgrading", done: "upgraded" },
|
|
48
53
|
disable: { doing: "Disabling", done: "disabled" },
|
|
@@ -136,6 +141,11 @@ export default function PluginsManager({ embedded = false } = {}) {
|
|
|
136
141
|
const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState(null);
|
|
137
142
|
/** How many changes (or the lookups that come before them) are waiting on the server. */
|
|
138
143
|
const [working, setWorking] = useState(0);
|
|
144
|
+
/** Set while an uploaded plugin is being sent to the server. */
|
|
145
|
+
const [uploading, setUploading] = useState(false);
|
|
146
|
+
/** An upload that would replace an installed plugin, waiting for the administrator to confirm it. */
|
|
147
|
+
const [replacing, setReplacing] = useState(null);
|
|
148
|
+
const fileInput = useRef(null);
|
|
139
149
|
const statusRequest = useRef(null);
|
|
140
150
|
/** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
|
|
141
151
|
const seenPurges = useRef(new Map());
|
|
@@ -373,6 +383,55 @@ export default function PluginsManager({ embedded = false } = {}) {
|
|
|
373
383
|
function upgrade(plugin, packageVersion) {
|
|
374
384
|
return onRow(plugin, () => changeVersion(plugin, packageVersion));
|
|
375
385
|
}
|
|
386
|
+
/** Sends `file` to the server as a plugin to install - replacing the installed plugin of that name only when `replace` is set. A plugin that is
|
|
387
|
+
* already installed isn't an error but a question, asked of the administrator before it is replaced. */
|
|
388
|
+
async function runUpload(file, replace) {
|
|
389
|
+
setUploading(true);
|
|
390
|
+
setError(null);
|
|
391
|
+
setRetryEnableUid(null);
|
|
392
|
+
try {
|
|
393
|
+
const uploaded = await track(uploadPlugin(file, { replace }));
|
|
394
|
+
applied(uploaded);
|
|
395
|
+
notify({
|
|
396
|
+
kind: "success",
|
|
397
|
+
title: `${uploaded.name}@${uploaded.packageVersion} uploaded`,
|
|
398
|
+
message: "The servers restart one at a time to load it.",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
if (!replace && err instanceof ApiRequestError && err.status === 409 && ALREADY_INSTALLED.test(err.message)) {
|
|
403
|
+
const installed = plugins.find((plugin) => err.message.includes(plugin.name));
|
|
404
|
+
setReplacing({
|
|
405
|
+
file,
|
|
406
|
+
question: installed
|
|
407
|
+
? `Replace ${installed.name} ${installed.packageVersion} with the uploaded pack?`
|
|
408
|
+
: "Replace the installed plugin with the uploaded pack?",
|
|
409
|
+
reason: err.message,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
else if (err instanceof ApiRequestError && err.status === 413) {
|
|
413
|
+
setError("That file is too large for the server to accept.");
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
setError(errorMessage(err, "Could not upload the plugin."));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
setUploading(false);
|
|
420
|
+
}
|
|
421
|
+
/** Takes the file the administrator chose, unless it can't be what `npm pack` produces. */
|
|
422
|
+
function onFileChosen(e) {
|
|
423
|
+
const file = e.target.files?.[0];
|
|
424
|
+
// So choosing the same file again (after fixing what was wrong) still counts as a change.
|
|
425
|
+
e.target.value = "";
|
|
426
|
+
if (!file) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!PLUGIN_PACK_NAME.test(file.name)) {
|
|
430
|
+
setError(`${file.name} isn't a plugin pack. Choose the .tgz file that npm pack produces.`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
void runUpload(file, false);
|
|
434
|
+
}
|
|
376
435
|
/** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
|
|
377
436
|
function reinstall(purge) {
|
|
378
437
|
const displayName = purgeName(purge);
|
|
@@ -523,9 +582,10 @@ export default function PluginsManager({ embedded = false } = {}) {
|
|
|
523
582
|
const retryEnable = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
|
|
524
583
|
const displayNameOf = (name) => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
|
|
525
584
|
const SectionHeading = embedded ? "h3" : "h2";
|
|
526
|
-
const addButton = (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setAdding(true), children: "Add by name" }));
|
|
527
585
|
const bulkBusy = bulk !== null;
|
|
528
|
-
|
|
586
|
+
const addButton = (_jsxs("div", { className: "flex flex-wrap gap-2 shrink-0", children: [_jsx("input", { ref: fileInput, type: "file", className: "hidden", accept: ".tgz,.tar.gz,application/gzip,application/x-gzip", "aria-label": "Plugin pack file", tabIndex: -1, onChange: onFileChosen }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", title: "Install a plugin from the .tgz file that npm pack produces", loading: uploading, disabled: uploading || bulkBusy, onClick: () => fileInput.current?.click(), children: uploading ? "Uploading\u2026" : "Upload" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setAdding(true), children: "Add by name" })] }));
|
|
587
|
+
return (_jsxs(_Fragment, { children: [(working > 0 || pending) && _jsx(PluginProgressBar, { label: working > 0 ? "Working on plugins" : "Applying plugin changes" }), embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0", checked: allowPrerelease, onChange: (e) => changePrerelease(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: "Allow pre-release versions" }), _jsx("span", { className: "text-xs text-text-muted", children: "Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to install. They can be unstable. This is remembered in this browser." })] })] }), bulkFailure && (_jsx(Alert, { children: _jsxs("div", { children: [bulkFailure.title, _jsx("ul", { className: "list-disc pl-5 mt-1", children: bulkFailure.reasons.map((reason) => (_jsx("li", { className: "break-words", children: reason }, reason))) })] }) })), error &&
|
|
588
|
+
(retryEnable ? (_jsxs(Alert, { children: [error, " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] })) : (_jsx(Alert, { children: error }))), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsxs("div", { className: "overflow-x-auto", children: [plugins.length > 0 && (_jsxs("div", { role: "toolbar", "aria-label": "Actions for several plugins", className: "flex flex-wrap items-center gap-2 py-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm mr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "h-4 w-4 shrink-0", "aria-label": "Select all plugins", checked: allSelected, disabled: bulkBusy, ref: (input) => {
|
|
529
589
|
if (input) {
|
|
530
590
|
input.indeterminate = selectedPlugins.length > 0 && !allSelected;
|
|
531
591
|
}
|
|
@@ -533,13 +593,15 @@ export default function PluginsManager({ embedded = false } = {}) {
|
|
|
533
593
|
versions: new Map(selectedUpgrades.map((plugin) => [plugin.uid, updates.get(plugin.uid).latestVersion])),
|
|
534
594
|
}), children: ["Upgrade", selectedUpgrades.length > 0 ? ` (${selectedUpgrades.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Disable selected plugins", disabled: bulkBusy || selectedEnabled.length === 0, onClick: () => void runBulk("disable", selectedEnabled), children: ["Disable", selectedEnabled.length > 0 ? ` (${selectedEnabled.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Uninstall selected plugins", disabled: bulkBusy || selectedPlugins.length === 0, onClick: () => setConfirmingBulkUninstall(selectedPlugins), children: ["Uninstall", selectedPlugins.length > 0 ? ` (${selectedPlugins.length})` : ""] }), _jsx(Button, { type: "button", className: "!w-auto ml-auto", "aria-label": "Upgrade all plugins", loading: bulk?.kind === "upgrade", disabled: bulkBusy, onClick: () => void upgradeAll(), children: "Upgrade all" }), bulk && (_jsxs("span", { "aria-live": "polite", className: "basis-full text-xs text-text-muted", children: [BULK_WORDS[bulk.kind].doing, " ", bulk.current, " (", bulk.done + 1, " of ", bulk.total, ")\u2026"] }))] })), _jsxs("table", { role: "table", className: "w-full text-sm border-collapse block lg:table", children: [_jsx("thead", { role: "rowgroup", className: "hidden lg:table-header-group", children: _jsxs("tr", { role: "row", children: [_jsx("th", { role: "columnheader", className: TH_CLASS, children: "Plugin" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Version" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Status" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsxs("tbody", { role: "rowgroup", className: "block lg:table-row-group", children: [plugins.map((plugin) => {
|
|
535
595
|
const update = updates.get(plugin.uid);
|
|
536
|
-
const
|
|
596
|
+
const uploaded = plugin.source === "upload";
|
|
597
|
+
// An uploaded pack has no registry version to be newer than.
|
|
598
|
+
const latest = !uploaded && update?.updateAvailable ? update.latestVersion : undefined;
|
|
537
599
|
const busy = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
|
|
538
600
|
const requires = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
|
|
539
601
|
const requiredBy = plugins
|
|
540
602
|
.filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
|
|
541
603
|
.map((other) => other.manifest.displayName);
|
|
542
|
-
return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
|
|
604
|
+
return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), uploaded && (_jsxs("div", { className: "text-xs text-text-muted mt-1 break-words", children: [_jsx("span", { className: `${BADGE_CLASS} bg-surface-alt text-text-muted`, children: "Uploaded" }), " ", plugin.uploadFilename, plugin.uploadedAt ? `, ${formatDate(plugin.uploadedAt)}` : ""] })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: uploaded ? "Install the registry version" : "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
|
|
543
605
|
}), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge), onInstall: () => void reinstall(purge) }, purge.uid)))] })] })] })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, prerelease: allowPrerelease, onInstall: (result) => install(result.name, result.version, result.name),
|
|
544
606
|
// The browser only offers an upgrade for a result it matched to one of these same `plugins`.
|
|
545
607
|
onUpgrade: (uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid), packageVersion) }), _jsx(AddPluginModal, { open: adding, prerelease: allowPrerelease, onClose: () => setAdding(false), onAdd: async (name, packageVersion, displayName) => {
|
|
@@ -554,7 +616,11 @@ export default function PluginsManager({ embedded = false } = {}) {
|
|
|
554
616
|
setUpgrading(null);
|
|
555
617
|
}
|
|
556
618
|
return problem;
|
|
557
|
-
} })),
|
|
619
|
+
} })), replacing && (_jsxs(Modal, { open: true, onClose: () => setReplacing(null), title: "Replace plugin?", children: [_jsx("p", { className: "text-sm mb-2", children: replacing.question }), _jsx("p", { className: "text-sm text-text-muted mb-4 break-words", children: replacing.reason }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setReplacing(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => {
|
|
620
|
+
const { file } = replacing;
|
|
621
|
+
setReplacing(null);
|
|
622
|
+
void runUpload(file, true);
|
|
623
|
+
}, children: "Replace" })] })] })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), confirmingBulkUninstall && (_jsx(BulkUninstallModal, { plugins: confirmingBulkUninstall, onClose: () => setConfirmingBulkUninstall(null), onConfirm: (purgeData) => {
|
|
558
624
|
const targets = confirmingBulkUninstall;
|
|
559
625
|
setConfirmingBulkUninstall(null);
|
|
560
626
|
void runBulk("uninstall", targets, { purgeData });
|
|
@@ -777,14 +843,19 @@ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
|
|
|
777
843
|
const [selected, setSelected] = useState(plugin.packageVersion);
|
|
778
844
|
const [error, setError] = useState(null);
|
|
779
845
|
const [busy, setBusy] = useState(false);
|
|
846
|
+
// An uploaded pack isn't a registry version, so any registry version is a change from it - the newest is offered first.
|
|
847
|
+
const uploaded = plugin.source === "upload";
|
|
780
848
|
useEffect(() => {
|
|
781
849
|
lookupPluginPackage(plugin.name, undefined, { prerelease })
|
|
782
850
|
.then((found) => {
|
|
783
851
|
setVersions(found.package.versions);
|
|
784
852
|
setLatest(found.package.latest);
|
|
853
|
+
if (uploaded && found.package.latest) {
|
|
854
|
+
setSelected(found.package.latest);
|
|
855
|
+
}
|
|
785
856
|
})
|
|
786
857
|
.catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
|
|
787
|
-
}, [plugin.name, prerelease]);
|
|
858
|
+
}, [plugin.name, prerelease, uploaded]);
|
|
788
859
|
async function save() {
|
|
789
860
|
setBusy(true);
|
|
790
861
|
setError(null);
|
|
@@ -794,7 +865,7 @@ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
|
|
|
794
865
|
setBusy(false);
|
|
795
866
|
}
|
|
796
867
|
}
|
|
797
|
-
return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || selected === plugin.packageVersion, onClick: () => void save(), children: "Save" })] })] }))] }));
|
|
868
|
+
return (_jsxs(Modal, { open: true, onClose: onClose, title: uploaded ? `Install the registry version of ${plugin.manifest.displayName}` : `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [uploaded && (_jsxs("p", { className: "text-sm text-text-muted", children: [plugin.manifest.displayName, " ", plugin.packageVersion, " was uploaded as ", plugin.uploadFilename, ". This replaces it with the version from the registry."] })), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", !uploaded && version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || (!uploaded && selected === plugin.packageVersion), onClick: () => void save(), children: uploaded ? "Install" : "Save" })] })] }))] }));
|
|
798
869
|
}
|
|
799
870
|
/** Lists the plugins a change also installs or enables, and makes the change once confirmed. */
|
|
800
871
|
function ConfirmDependenciesModal({ change, displayNameOf, onClose, onDone, }) {
|
|
@@ -64,6 +64,14 @@ export interface Plugin {
|
|
|
64
64
|
* predates it, which reads as nothing being configured. */
|
|
65
65
|
configured?: Record<string, PluginConfiguredSetting>;
|
|
66
66
|
manifest: PluginManifest;
|
|
67
|
+
/** Where the installed package came from: the npm registry (also what a server that predates uploads means), or a `.tgz` an administrator uploaded. */
|
|
68
|
+
source?: "registry" | "upload";
|
|
69
|
+
/** The name of the uploaded file, for a plugin with `source` `upload`. */
|
|
70
|
+
uploadFilename?: string;
|
|
71
|
+
/** ISO time the file was uploaded. */
|
|
72
|
+
uploadedAt?: string;
|
|
73
|
+
/** The uid of the administrator who uploaded it. */
|
|
74
|
+
uploadedByUserUid?: string;
|
|
67
75
|
}
|
|
68
76
|
/** Mirrors `@rapidmx/restapi`'s `RegistryPackageVersion`. `manifest` is a message when the version isn't a
|
|
69
77
|
* loadable plugin. */
|
|
@@ -260,6 +268,16 @@ export declare function planPluginChange(name: string, packageVersion?: string,
|
|
|
260
268
|
* requires first. Refused (409) when a requirement conflicts with an installed plugin's version, or when
|
|
261
269
|
* `expectedPlan` is given and no longer matches what adding it would install and enable. */
|
|
262
270
|
export declare function addPlugin(name: string, packageVersion?: string, expectedPlan?: PluginExpectedPlan, client?: ApiClient): Promise<AddPluginResult>;
|
|
271
|
+
/**
|
|
272
|
+
* Installs a plugin from `file`, the `.tgz` that `npm pack` produces, instead of from the registry - to try a new version before it is released, or an
|
|
273
|
+
* alternative distribution. The file's raw bytes are the request body (not multipart); its name travels in the query string. Needs an elevated
|
|
274
|
+
* administrator (403 `api-104` otherwise) and uploads switched on by the operator (403). Refused with 409 when a plugin of that name is already
|
|
275
|
+
* installed - unless `replace` is set, which swaps it for the uploaded one - or when a plugin it requires isn't installed; 400 when it isn't a
|
|
276
|
+
* loadable plugin and 413 when it is too large. Like adding one, it starts a rollout.
|
|
277
|
+
*/
|
|
278
|
+
export declare function uploadPlugin(file: File, options?: {
|
|
279
|
+
replace?: boolean;
|
|
280
|
+
}, client?: ApiClient): Promise<Plugin>;
|
|
263
281
|
export declare function updatePlugin(uid: string, input: UpdatePluginInput, client?: ApiClient): Promise<Plugin>;
|
|
264
282
|
/**
|
|
265
283
|
* Removes a plugin. Refused (409) while an enabled plugin requires it, as is disabling it with `updatePlugin()`.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* each server copy installs the new plugin set and restarts itself, one copy at a time, and reports back through
|
|
9
9
|
* `getPluginStatus()`.
|
|
10
10
|
*/
|
|
11
|
-
import { withClient } from "../util/api.js";
|
|
11
|
+
import { withClient, withClientRaw } from "../util/api.js";
|
|
12
12
|
/** Whether `value` is the placeholder a saved secret setting is shown as. */
|
|
13
13
|
export function isSecretSetting(value) {
|
|
14
14
|
return typeof value === "object" && value !== null && value.secret === true;
|
|
@@ -85,6 +85,20 @@ export function planPluginChange(name, packageVersion, options = {}, client) {
|
|
|
85
85
|
export function addPlugin(name, packageVersion, expectedPlan, client) {
|
|
86
86
|
return withClient(client, BASE, { method: "POST", body: JSON.stringify({ name, packageVersion, expectedPlan }) });
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Installs a plugin from `file`, the `.tgz` that `npm pack` produces, instead of from the registry - to try a new version before it is released, or an
|
|
90
|
+
* alternative distribution. The file's raw bytes are the request body (not multipart); its name travels in the query string. Needs an elevated
|
|
91
|
+
* administrator (403 `api-104` otherwise) and uploads switched on by the operator (403). Refused with 409 when a plugin of that name is already
|
|
92
|
+
* installed - unless `replace` is set, which swaps it for the uploaded one - or when a plugin it requires isn't installed; 400 when it isn't a
|
|
93
|
+
* loadable plugin and 413 when it is too large. Like adding one, it starts a rollout.
|
|
94
|
+
*/
|
|
95
|
+
export function uploadPlugin(file, options = {}, client) {
|
|
96
|
+
const params = new URLSearchParams({ filename: file.name });
|
|
97
|
+
if (options.replace) {
|
|
98
|
+
params.set("replace", "true");
|
|
99
|
+
}
|
|
100
|
+
return withClientRaw(client, `${BASE}/upload?${params.toString()}`, "POST", file, "application/gzip", "Could not upload the plugin.");
|
|
101
|
+
}
|
|
88
102
|
export function updatePlugin(uid, input, client) {
|
|
89
103
|
return withClient(client, `${BASE}/${encodeURIComponent(uid)}`, { method: "PUT", body: JSON.stringify(input) });
|
|
90
104
|
}
|
package/lib/admin/pluginsApi.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* each server copy installs the new plugin set and restarts itself, one copy at a time, and reports back through
|
|
9
9
|
* `getPluginStatus()`.
|
|
10
10
|
*/
|
|
11
|
-
import { ApiClient, withClient } from "../util/api.js";
|
|
11
|
+
import { ApiClient, withClient, withClientRaw } from "../util/api.js";
|
|
12
12
|
|
|
13
13
|
/** Mirrors `@rapidmx/restapi`'s `PluginSettingDefinition`. */
|
|
14
14
|
export interface PluginSettingDefinition {
|
|
@@ -78,6 +78,14 @@ export interface Plugin {
|
|
|
78
78
|
* predates it, which reads as nothing being configured. */
|
|
79
79
|
configured?: Record<string, PluginConfiguredSetting>;
|
|
80
80
|
manifest: PluginManifest;
|
|
81
|
+
/** Where the installed package came from: the npm registry (also what a server that predates uploads means), or a `.tgz` an administrator uploaded. */
|
|
82
|
+
source?: "registry" | "upload";
|
|
83
|
+
/** The name of the uploaded file, for a plugin with `source` `upload`. */
|
|
84
|
+
uploadFilename?: string;
|
|
85
|
+
/** ISO time the file was uploaded. */
|
|
86
|
+
uploadedAt?: string;
|
|
87
|
+
/** The uid of the administrator who uploaded it. */
|
|
88
|
+
uploadedByUserUid?: string;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/** Mirrors `@rapidmx/restapi`'s `RegistryPackageVersion`. `manifest` is a message when the version isn't a
|
|
@@ -331,6 +339,21 @@ export function addPlugin(
|
|
|
331
339
|
return withClient(client, BASE, { method: "POST", body: JSON.stringify({ name, packageVersion, expectedPlan }) });
|
|
332
340
|
}
|
|
333
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Installs a plugin from `file`, the `.tgz` that `npm pack` produces, instead of from the registry - to try a new version before it is released, or an
|
|
344
|
+
* alternative distribution. The file's raw bytes are the request body (not multipart); its name travels in the query string. Needs an elevated
|
|
345
|
+
* administrator (403 `api-104` otherwise) and uploads switched on by the operator (403). Refused with 409 when a plugin of that name is already
|
|
346
|
+
* installed - unless `replace` is set, which swaps it for the uploaded one - or when a plugin it requires isn't installed; 400 when it isn't a
|
|
347
|
+
* loadable plugin and 413 when it is too large. Like adding one, it starts a rollout.
|
|
348
|
+
*/
|
|
349
|
+
export function uploadPlugin(file: File, options: { replace?: boolean } = {}, client?: ApiClient): Promise<Plugin> {
|
|
350
|
+
const params = new URLSearchParams({ filename: file.name });
|
|
351
|
+
if (options.replace) {
|
|
352
|
+
params.set("replace", "true");
|
|
353
|
+
}
|
|
354
|
+
return withClientRaw(client, `${BASE}/upload?${params.toString()}`, "POST", file, "application/gzip", "Could not upload the plugin.");
|
|
355
|
+
}
|
|
356
|
+
|
|
334
357
|
export function updatePlugin(uid: string, input: UpdatePluginInput, client?: ApiClient): Promise<Plugin> {
|
|
335
358
|
return withClient(client, `${BASE}/${encodeURIComponent(uid)}`, { method: "PUT", body: JSON.stringify(input) });
|
|
336
359
|
}
|
package/package.json
CHANGED