feat(scripts): add zip file installation functionality for plugins
- Introduced the ability to install plugins from zip files, enhancing the script management interface. - Implemented file selection and base64 encoding for zip files before installation. - Added confirmation dialog for installing plugins from zip, including warnings for existing plugins. - Integrated user role checks to restrict zip installation to admin users only. - Improved UI feedback during the installation process with loading indicators.
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
|
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2, LuUpload } from "react-icons/lu";
|
||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useDeleteScript,
|
useDeleteScript,
|
||||||
useDuplicatePlugin,
|
useDuplicatePlugin,
|
||||||
useForkScript,
|
useForkScript,
|
||||||
useInstallPlugin,
|
useInstallPlugin,
|
||||||
|
useMe,
|
||||||
useScripts,
|
useScripts,
|
||||||
} from "../api/hooks.js";
|
} from "../api/hooks.js";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
|
||||||
@@ -15,6 +16,21 @@ import { TagBadge } from "../components/TagBadge.jsx";
|
|||||||
import { scriptTags } from "../lib/script.js";
|
import { scriptTags } from "../lib/script.js";
|
||||||
import { useNotifications } from "../notifications.jsx";
|
import { useNotifications } from "../notifications.jsx";
|
||||||
|
|
||||||
|
const BASE64_CHUNK = 0x8000;
|
||||||
|
|
||||||
|
function uint8ArrayToBase64(bytes) {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileToBase64(file) {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
return uint8ArrayToBase64(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
export function ScriptsPage() {
|
export function ScriptsPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,7 +46,13 @@ export function ScriptsPage() {
|
|||||||
const fork = useForkScript();
|
const fork = useForkScript();
|
||||||
const duplicate = useDuplicatePlugin();
|
const duplicate = useDuplicatePlugin();
|
||||||
const install = useInstallPlugin();
|
const install = useInstallPlugin();
|
||||||
|
const me = useMe();
|
||||||
|
const isAdmin = me.data?.user?.role === "admin";
|
||||||
const { notify } = useNotifications();
|
const { notify } = useNotifications();
|
||||||
|
const [pendingZip, setPendingZip] = useState(null);
|
||||||
|
const [zipEncoding, setZipEncoding] = useState(false);
|
||||||
|
const zipInFlight = useRef(false);
|
||||||
|
const zipBusy = zipEncoding || install.isPending;
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const term = query.trim().toLowerCase();
|
const term = query.trim().toLowerCase();
|
||||||
@@ -49,6 +71,44 @@ export function ScriptsPage() {
|
|||||||
});
|
});
|
||||||
}, [scripts, query]);
|
}, [scripts, query]);
|
||||||
|
|
||||||
|
function onPickZip(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
install.reset();
|
||||||
|
setPendingZip(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPendingZip() {
|
||||||
|
if (!pendingZip || zipInFlight.current) return;
|
||||||
|
zipInFlight.current = true;
|
||||||
|
setZipEncoding(true);
|
||||||
|
try {
|
||||||
|
const zipBase64 = await fileToBase64(pendingZip);
|
||||||
|
install.mutate(
|
||||||
|
{ source: "zip", zipBase64, overwrite: true },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPendingZip(null);
|
||||||
|
const ref =
|
||||||
|
data.scriptRef ?? (data.id ? `plugin/${data.id}` : "plugin");
|
||||||
|
notify.success(`Installed ${ref} — drain-restart to load workers`);
|
||||||
|
if (data.warning) notify.warning(data.warning);
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(errorMessage(err)),
|
||||||
|
onSettled: () => {
|
||||||
|
zipInFlight.current = false;
|
||||||
|
setZipEncoding(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
zipInFlight.current = false;
|
||||||
|
setZipEncoding(false);
|
||||||
|
notify.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (editName) {
|
if (editName) {
|
||||||
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
||||||
}
|
}
|
||||||
@@ -71,7 +131,7 @@ export function ScriptsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
disabled={install.isPending}
|
disabled={zipBusy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
install.mutate(
|
install.mutate(
|
||||||
{ source: "example", exampleId: "get-current-time", overwrite: true },
|
{ source: "example", exampleId: "get-current-time", overwrite: true },
|
||||||
@@ -84,6 +144,23 @@ export function ScriptsPage() {
|
|||||||
>
|
>
|
||||||
Install example
|
Install example
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin ? (
|
||||||
|
<label className={`btn btn-outline btn-sm ${zipBusy ? "btn-disabled" : ""}`}>
|
||||||
|
{zipBusy ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : (
|
||||||
|
<LuUpload className="size-4" />
|
||||||
|
)}
|
||||||
|
Install from zip
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
className="hidden"
|
||||||
|
disabled={zipBusy}
|
||||||
|
onChange={onPickZip}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||||
<LuPlus className="size-4" />
|
<LuPlus className="size-4" />
|
||||||
Add plugin
|
Add plugin
|
||||||
@@ -341,6 +418,30 @@ export function ScriptsPage() {
|
|||||||
</dialog>
|
</dialog>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(pendingZip)}
|
||||||
|
title="Install plugin from zip?"
|
||||||
|
message={
|
||||||
|
pendingZip ? (
|
||||||
|
<>
|
||||||
|
Installing{" "}
|
||||||
|
<span className="font-mono">{pendingZip.name}</span> replaces an
|
||||||
|
existing plugin with the same id. Drain-restart workers after
|
||||||
|
install so new dependencies load.
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
confirmLabel="Install"
|
||||||
|
confirmClass="btn-primary"
|
||||||
|
error={install.isError ? errorMessage(install.error) : null}
|
||||||
|
loading={zipBusy}
|
||||||
|
onCancel={() => {
|
||||||
|
if (zipBusy) return;
|
||||||
|
setPendingZip(null);
|
||||||
|
}}
|
||||||
|
onConfirm={installPendingZip}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(confirmDelete)}
|
open={Boolean(confirmDelete)}
|
||||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||||
|
|||||||
Reference in New Issue
Block a user