diff --git a/pkgs/clan-app/ui/src/api/clan/client-call.ts b/pkgs/clan-app/ui/src/api/clan/client-call.ts new file mode 100644 index 000000000..4cee9ac90 --- /dev/null +++ b/pkgs/clan-app/ui/src/api/clan/client-call.ts @@ -0,0 +1,69 @@ +import { API } from "@/api/API"; + +type Methods = keyof API; +interface Header { + logging?: { group_path: string[] }; + op_key?: string; +} +type Body = API[Method]["arguments"]; +type Response = API[Method]["return"]; +async function call( + method: Method, + { + body, + header, + signal, + }: { + body?: Body; + header?: Header; + signal?: AbortSignal; + } = {}, +): Promise> { + const fn = ( + window as unknown as Record< + Method, + (args: { body?: Body; header?: Header }) => Promise<{ + body: Response; + header: Record; + }> + > + )[method]; + if (typeof fn != "function") { + throw new Error(`Cannot call clan non-existant method: ${method}`); + } + + const taskId = window.crypto.randomUUID(); + let isDone = false; + signal?.addEventListener("abort", async () => { + if (isDone) return; + await call("delete_task", { body: { task_id: taskId } }); + }); + + const res = await fn({ + body, + header: { + ...header, + op_key: taskId, + }, + }); + isDone = true; + + if (res.body.status == "error") { + const err = res.body.errors[0]; + throw new Error(`${err.message}: ${err.description}`); + } + + return res.body; +} + +export default { + async get(url: Methods, { signal }: { signal?: AbortSignal }) { + return await call(url, { signal }); + }, + async post( + url: Method, + { signal, body }: { signal?: AbortSignal; body: Body }, + ) { + return await call(url, { signal, body }); + }, +}; diff --git a/pkgs/clan-app/ui/src/api/clan/client-fetch.ts b/pkgs/clan-app/ui/src/api/clan/client-fetch.ts new file mode 100644 index 000000000..91ad86f4e --- /dev/null +++ b/pkgs/clan-app/ui/src/api/clan/client-fetch.ts @@ -0,0 +1,13 @@ +export default { + async get(url: string, { signal }: { signal?: AbortSignal }) { + const res = await fetch(url, { signal }); + return await res.json(); + }, + async post( + url: string, + { signal, body }: { body?: unknown; signal?: AbortSignal }, + ) { + const res = await fetch(url, { signal, body: JSON.stringify(body) }); + return await res.json(); + }, +}; diff --git a/pkgs/clan-app/ui/src/api/clan/index.ts b/pkgs/clan-app/ui/src/api/clan/index.ts new file mode 100644 index 000000000..2f1707049 --- /dev/null +++ b/pkgs/clan-app/ui/src/api/clan/index.ts @@ -0,0 +1,125 @@ +import client from "@api/clan/client"; + +// TODO: allow users to select a template +export async function createClan({ + name, + path, + description, +}: { + name: string; + path: string; + description: string; +}): Promise { + await client.post("create_clan", { + body: { + opts: { + dest: path, + template: "minimal", + initial: { + name, + description, + }, + }, + }, + }); + + await client.post("create_service_instance", { + body: { + flake: { + identifier: path, + }, + module_ref: { + name: "admin", + input: "clan-core", + }, + roles: { + default: { + tags: { + all: {}, + }, + }, + }, + }, + }); + + await client.post("create_secrets_user", { + body: { + flake_dir: path, + }, + }); +} + +export async function updateMachine({ + clan, + name, + targetHost, + port, + password, + signal, +}: { + clan: string; + name: string; + targetHost: string; + port?: number; + password?: string; + signal?: AbortSignal; +}): Promise { + await client.post("run_machine_update", { + signal, + body: { + machine: { + flake: { identifier: clan }, + name, + }, + build_host: null, + target_host: { + address: targetHost, + port, + password, + ssh_options: { + StrictHostKeyChecking: "no", + UserKnownHostsFile: "/dev/null", + }, + }, + }, + }); +} + +export async function installMachine({ + clan, + name, + targetHost, + port, + password, + signal, +}: { + clan: string; + name: string; + targetHost: string; + port?: number; + password?: string; + signal?: AbortSignal; +}): Promise { + await client.post("run_machine_install", { + signal, + body: { + opts: { + machine: { + name, + flake: { + identifier: clan, + }, + }, + }, + target_host: { + address: targetHost, + port, + password, + ssh_options: { + StrictHostKeyChecking: "no", + UserKnownHostsFile: "/dev/null", + }, + }, + }, + }); +} diff --git a/pkgs/clan-app/ui/src/api/index.ts b/pkgs/clan-app/ui/src/api/index.ts new file mode 100644 index 000000000..c660fe83f --- /dev/null +++ b/pkgs/clan-app/ui/src/api/index.ts @@ -0,0 +1 @@ +export * as clan from "./clan"; diff --git a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx index ce6189cb1..1ae236788 100644 --- a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx +++ b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx @@ -38,6 +38,7 @@ import { useApiClient } from "@/src/hooks/ApiClient"; import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; import { Tooltip } from "@/src/components/Tooltip/Tooltip"; import { CubeConstruction } from "@/src/components/CubeConstruction/CubeConstruction"; +import * as api from "@/src/api"; type State = "welcome" | "setup" | "creating"; @@ -200,56 +201,22 @@ export const Onboarding: Component = (props) => { event, ) => { const path = `${directory}/${name}`; - - const req = client.fetch("create_clan", { - opts: { - dest: path, - // todo allow users to select a template - template: "minimal", - initial: { - name, - description, - }, - }, - }); - setState("creating"); - - const resp = await req.result; - - // Set up default services - await client.fetch("create_service_instance", { - flake: { - identifier: path, - }, - module_ref: { - name: "admin", - input: "clan-core", - }, - roles: { - default: { - tags: { - all: {}, - }, - }, - }, - }).result; - - await client.fetch("create_secrets_user", { - flake_dir: path, - }).result; - - if (resp.status === "error") { - setWelcomeError(resp.errors[0].message); + try { + await api.clan.createClan({ + name, + path, + description, + }); + } catch (err) { + setWelcomeError(String(err)); setState("welcome"); return; } - if (resp.status === "success") { - addClanURI(path); - setActiveClanURI(path); - navigateToClan(navigate, path); - } + addClanURI(path); + setActiveClanURI(path); + navigateToClan(navigate, path); }; return ( diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/InstallMachine.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/InstallMachine.tsx index 43d3f34d0..aa5a117d8 100644 --- a/pkgs/clan-app/ui/src/workflows/InstallMachine/InstallMachine.tsx +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/InstallMachine.tsx @@ -57,7 +57,7 @@ export interface InstallStoreType { machineName: string; mainDisk?: string; // ...TODO Vars - progress: ApiCall<"run_machine_install" | "run_machine_update">; + progress: AbortController; promptValues: PromptValues; prepareStep: "disk" | "generators" | "install"; }; diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx index 90c979cbb..860a1db14 100644 --- a/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx @@ -16,7 +16,7 @@ import { Button } from "@/src/components/Button/Button"; import Icon from "@/src/components/Icon/Icon"; import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify"; import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar"; -import { useApiClient } from "@/src/hooks/ApiClient"; +import * as api from "@/src/api"; import { useClanURI } from "@/src/hooks/clan"; import { AlertProps } from "@/src/components/Alert/Alert"; import usbLogo from "@/logos/usb-stick-min.png?url"; @@ -34,54 +34,43 @@ const UpdateStepper = (props: UpdateStepperProps) => { const clanURI = useClanURI(); - const client = useApiClient(); const handleUpdate = async () => { console.log("Starting update for", store.install.machineName); - if (!store.install.targetHost) { + const targetHost = store.install.targetHost; + if (!targetHost) { console.error("No target host specified, API requires it"); return; } - const port = store.install.port ? parseInt(store.install.port, 10) : undefined; - const call = client.fetch("run_machine_update", { - machine: { - flake: { identifier: clanURI }, + const abortController = new AbortController(); + set("install", "progress", abortController); + try { + await api.clan.updateMachine({ + clan: clanURI, name: store.install.machineName, - }, - build_host: null, - target_host: { - address: store.install.targetHost, + targetHost, port, password: store.install.password, - ssh_options: { - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - }, - }, - }); - // For cancel - set("install", "progress", call); - - const result = await call.result; - - if (result.status === "error") { - console.error("Update failed", result.errors); + signal: abortController.signal, + }); + } catch (err) { + if (abortController.signal.aborted) { + return; + } setAlert(() => ({ type: "error", title: "Update failed", - description: result.errors[0].message, + description: String(err), })); stepSignal.previous(); return; } - if (result.status === "success") { - stepSignal.next(); - return; - } + + stepSignal.next(); }; return ( @@ -125,10 +114,7 @@ const UpdateProgress = () => { const [store, get] = getStepStore(stepSignal); const handleCancel = async () => { - const progress = store.install.progress; - if (progress) { - await progress.cancel(); - } + store.install.progress.abort(); stepSignal.previous(); }; const updateState = diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx index 0b4d22642..c9ea65e43 100644 --- a/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx @@ -33,6 +33,7 @@ import { } from "@/src/hooks/queries"; import { useClanURI } from "@/src/hooks/clan"; import { useApiClient } from "@/src/hooks/ApiClient"; +import * as api from "@/src/api"; import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify"; import { Loader } from "@/src/components/Loader/Loader"; import { Button as KButton } from "@kobalte/core/button"; @@ -731,33 +732,29 @@ const InstallSummary = () => { ? parseInt(store.install.port, 10) : undefined; - const runInstall = client.fetch("run_machine_install", { - opts: { - machine: { - name: store.install.machineName, - flake: { - identifier: clanUri, - }, - }, - }, - target_host: { - address: store.install.targetHost, + const abortController = new AbortController(); + set("install", "progress", abortController); + + try { + await api.clan.installMachine({ + clan: clanUri, + name: store.install.machineName, + targetHost: store.install.targetHost, port, password: store.install.password, - ssh_options: { - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - }, - }, - }); + signal: abortController.signal, + }); + } catch (err) { + if (abortController.signal.aborted) { + return; + } + // TODO: show other errors + } set("install", (s) => ({ ...s, prepareStep: "install", - progress: runInstall, })); - await runInstall.result; - stepSignal.setActiveStep("install:done"); }; return ( @@ -818,10 +815,7 @@ const InstallProgress = () => { const [store, get] = getStepStore(stepSignal); const handleCancel = async () => { - const progress = store.install.progress; - if (progress) { - await progress.cancel(); - } + store.install.progress.abort(); stepSignal.previous(); }; const installState = useNotifyOrigin>( diff --git a/pkgs/clan-app/ui/tsconfig.json b/pkgs/clan-app/ui/tsconfig.json index f7aed01ca..3544efda6 100644 --- a/pkgs/clan-app/ui/tsconfig.json +++ b/pkgs/clan-app/ui/tsconfig.json @@ -15,7 +15,8 @@ "allowJs": true, "isolatedModules": true, "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@api/clan/client": ["./src/api/clan/client-call"] } } } diff --git a/pkgs/clan-app/ui/vite.config.ts b/pkgs/clan-app/ui/vite.config.ts index 3a99f02e1..dd979b25e 100644 --- a/pkgs/clan-app/ui/vite.config.ts +++ b/pkgs/clan-app/ui/vite.config.ts @@ -27,7 +27,9 @@ function regenPythonApiOnFileChange() { export default defineConfig({ resolve: { alias: { - "@": path.resolve(__dirname, "./"), // Adjust the path as needed + "@": path.resolve(__dirname, "./"), + // Different script can be used based on different env vars + "@api/clan/client": path.resolve(__dirname, "./src/api/clan/client-call"), }, }, base: "./",