import { useContext, useEffect, useRef, useState } from "react"; import { Alert, Typography } from "antd"; import * as lib from "@clusterio/lib"; import { useAccount } from "../model/account"; import { useHosts } from "../model/host"; import { ExclusiveRunner } from "../util/exclusive"; import notify, { notifyErrorHandler } from "../util/notify"; import ControlContext from "./ControlContext"; const { Text } = Typography; export type InstallTarget = { name: string, address: lib.AddressShorthand }; export type PackageOperation = { kind: "install" | "update", target: InstallTarget, packageSpec: string }; export type RemoteAccess = { canInstall: (address: lib.AddressShorthand) => boolean, canUpdate: (address: lib.AddressShorthand) => boolean, }; export type PluginInstaller = { access: RemoteAccess, installing: boolean, restarting: boolean, /** Install a package, optionally pinned as name@version, on every target */ install: (packageSpec: string, targets: InstallTarget[]) => Promise, /** Run package operations one at a time, a standalone controller and host share one installation */ run: (operations: PackageOperation[]) => Promise, /** Run a restart, never at the same time as package operations */ restart: (restartMachines: () => Promise) => Promise, }; type MachineAccess = { install: boolean, update: boolean }; const defaultAccess: MachineAccess = { install: true, update: true }; export function addressKey(address: lib.AddressShorthand) { return address === "controller" ? "controller" : String((address as { hostId: number }).hostId); } // Which machines accept remote plugin installs and updates, assumed true when the config can't be read. function useRemoteAccess(): RemoteAccess { const control = useContext(ControlContext); const account = useAccount(); const [hosts] = useHosts(); const [controllerAccess, setControllerAccess] = useState(defaultAccess); const [hostsAccess, setHostsAccess] = useState(new Map()); const connectedHostIds = [...hosts.values()].filter(host => host.connected).map(host => host.id); const canGetControllerConfig = account.hasPermission("core.controller.get_config"); const canGetHostConfig = account.hasPermission("core.host.get_config"); useEffect(() => { if (!canGetControllerConfig) { setControllerAccess(defaultAccess); return undefined; } let canceled = false; control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => { if (canceled) { return; } const config = lib.ControllerConfig.fromJSON(serializedConfig, "control"); setControllerAccess({ install: config.get("controller.allow_plugin_install"), update: config.get("controller.allow_plugin_updates"), }); }).catch(notifyErrorHandler("Failed to fetch controller config")); return () => { canceled = true; }; }, [canGetControllerConfig]); useEffect(() => { if (!canGetHostConfig) { setHostsAccess(new Map()); return undefined; } let canceled = false; Promise.all(connectedHostIds.map(async hostId => { try { const serializedConfig = await control.sendTo({ hostId }, new lib.HostConfigGetRequest()); const config = lib.HostConfig.fromJSON(serializedConfig, "control"); return [hostId, { install: config.get("host.allow_plugin_install"), update: config.get("host.allow_plugin_updates"), }] as const; } catch (err) { return [hostId, defaultAccess] as const; } })).then(entries => { if (!canceled) { setHostsAccess(new Map(entries)); } }); return () => { canceled = true; }; }, [connectedHostIds.join(","), canGetHostConfig]); const accessOf = (address: lib.AddressShorthand) => (address === "controller" ? controllerAccess : hostsAccess.get((address as { hostId: number }).hostId) ?? defaultAccess ); return { canInstall: address => accessOf(address).install, canUpdate: address => accessOf(address).update, }; } /** * Installs and updates plugins and reports the outcome. Owned by the plugins page so the busy * state is shared between the browser and update actions and survives browsing. */ export function usePluginInstaller(onDone?: (addresses: lib.AddressShorthand[]) => void): PluginInstaller { const control = useContext(ControlContext); const access = useRemoteAccess(); const [busy, setBusy] = useState<"installing" | "restarting">(); // A confirm opened before a run started can still be clicked while it runs const runner = useRef(new ExclusiveRunner(setBusy)).current; async function exclusive(state: "installing" | "restarting", action: () => Promise) { if (!await runner.run(state, action)) { notify("Another plugin change or restart is still in progress", "warning"); } } async function runOperations(operations: PackageOperation[]) { const done: string[] = []; try { for (const { kind, target, packageSpec } of operations) { const request = kind === "install" ? new lib.PluginInstallRequest(packageSpec) : new lib.PluginUpdateRequest(packageSpec); try { await control.sendTo(target.address, request); done.push(`${kind === "install" ? "Installed" : "Updated"} ${packageSpec} on ${target.name}`); } catch (err: any) { notifyErrorHandler(`Failed to ${kind} ${packageSpec} on ${target.name}`)(err); } } if (done.length) { notify( done.length === 1 ? done[0] : `Completed ${done.length} plugin changes`, "success", <> {done.length > 1 && done.map(line =>
{line}
)}
A restart is required to apply the changes.
, ); } } finally { onDone?.(operations.map(operation => operation.target.address)); } } async function run(operations: PackageOperation[]) { await exclusive("installing", () => runOperations(operations)); } async function restart(restartMachines: () => Promise) { await exclusive("restarting", restartMachines); } async function install(packageSpec: string, targets: InstallTarget[]) { await run(targets.map(target => ({ kind: "install", target, packageSpec }))); } return { access, installing: busy === "installing", restarting: busy === "restarting", install, run, restart, }; } // Remote installs can only be enabled locally on each machine, so tell the user how. export function RemoteInstallDisabledNotice(props: { controller: boolean, hosts: string[] }) { const names = [...(props.controller ? ["the controller"] : []), ...props.hosts]; return For security reasons this can't be changed from the web UI. To allow installs, stop the machine and run the following in its installation directory, then start it again: {props.controller &&
Controller: npx clusteriocontroller config set controller.allow_plugin_install true
} {props.hosts.length > 0 &&
Host: npx clusteriohost config set host.allow_plugin_install true
} } />; }