ui: new api call design

- api functions exist under api.*
- they accept an abort signal and return a promise
- they can be swapped out at build time depending on the platform
  (e.g.,window.method on desktop, fetch on mobile)
- TanStack Query functions should only be used in components, and
  only when we need its features, favoring simpler api.* calls
This commit is contained in:
Glen Huang
2025-09-29 21:33:16 +08:00
parent adb82a8414
commit a268be69fe
10 changed files with 263 additions and 105 deletions

View File

@@ -0,0 +1,69 @@
import { API } from "@/api/API";
type Methods = keyof API;
interface Header {
logging?: { group_path: string[] };
op_key?: string;
}
type Body<Method extends Methods> = API[Method]["arguments"];
type Response<Method extends Methods> = API[Method]["return"];
async function call<Method extends Methods>(
method: Method,
{
body,
header,
signal,
}: {
body?: Body<Method>;
header?: Header;
signal?: AbortSignal;
} = {},
): Promise<Response<Method>> {
const fn = (
window as unknown as Record<
Method,
(args: { body?: Body<Method>; header?: Header }) => Promise<{
body: Response<Method>;
header: Record<string, unknown>;
}>
>
)[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<Method extends Methods>(
url: Method,
{ signal, body }: { signal?: AbortSignal; body: Body<Method> },
) {
return await call(url, { signal, body });
},
};

View File

@@ -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();
},
};

View File

@@ -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<void> {
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<void> {
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<void> {
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",
},
},
},
});
}

View File

@@ -0,0 +1 @@
export * as clan from "./clan";

View File

@@ -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<RouteSectionProps> = (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);
}
};
return (

View File

@@ -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";
};

View File

@@ -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",
},
},
signal: abortController.signal,
});
// For cancel
set("install", "progress", call);
const result = await call.result;
if (result.status === "error") {
console.error("Update failed", result.errors);
} 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;
}
};
return (
@@ -125,10 +114,7 @@ const UpdateProgress = () => {
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
const handleCancel = async () => {
const progress = store.install.progress;
if (progress) {
await progress.cancel();
}
store.install.progress.abort();
stepSignal.previous();
};
const updateState =

View File

@@ -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: {
const abortController = new AbortController();
set("install", "progress", abortController);
try {
await api.clan.installMachine({
clan: clanUri,
name: store.install.machineName,
flake: {
identifier: clanUri,
},
},
},
target_host: {
address: store.install.targetHost,
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<InstallStoreType>(stepSignal);
const handleCancel = async () => {
const progress = store.install.progress;
if (progress) {
await progress.cancel();
}
store.install.progress.abort();
stepSignal.previous();
};
const installState = useNotifyOrigin<ProcessMessage<unknown, InstallTopic>>(

View File

@@ -15,7 +15,8 @@
"allowJs": true,
"isolatedModules": true,
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@api/clan/client": ["./src/api/clan/client-call"]
}
}
}

View File

@@ -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: "./",