ui-2d: Fix build errors

This commit is contained in:
Qubasa
2025-07-02 15:59:50 +07:00
parent 672db4a33f
commit 8e00363584
21 changed files with 220 additions and 365 deletions

View File

@@ -1,84 +0,0 @@
{
"name": "@clan/ui",
"version": "0.0.1",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "npm run check && npm run test && vite build && npm run convert-html",
"convert-html": "node gtk.webview.js",
"serve": "vite preview",
"check": "tsc --noEmit --skipLibCheck && eslint ./src --fix",
"knip": "knip --fix",
"test": "vitest run --project unit --typecheck",
"storybook": "storybook",
"storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook",
"test-storybook-update-snapshots": "vitest run --project storybook --update",
"test-storybook-static": "npm run storybook-build && concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'npx http-server storybook-static --port 6006 --silent' 'npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
},
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.3.0",
"@kachurun/storybook-solid": "^9.0.11",
"@kachurun/storybook-solid-vite": "^9.0.11",
"@storybook/addon-a11y": "^9.0.8",
"@storybook/addon-docs": "^9.0.8",
"@storybook/addon-links": "^9.0.8",
"@storybook/addon-onboarding": "^9.0.8",
"@storybook/addon-vitest": "^9.0.8",
"@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.15.19",
"@vitest/browser": "^3.2.3",
"autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"concurrently": "^9.1.2",
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"solid-devtools": "^0.34.0",
"storybook": "^9.0.8",
"tailwindcss": "^4.0.0",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"vite": "^7.0.0",
"vite-plugin-solid": "^2.8.2",
"vite-plugin-solid-svg": "^0.8.1",
"vitest": "^3.2.3"
},
"dependencies": {
"@floating-ui/dom": "^1.6.8",
"@kobalte/core": "^0.13.10",
"@kobalte/tailwindcss": "^0.9.0",
"@modular-forms/solid": "^0.25.1",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.15.3",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0",
"corvu": "^0.7.1",
"nanoid": "^5.0.7",
"solid-js": "^1.9.7",
"solid-toast": "^0.5.0"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",
"@esbuild/darwin-x64": "^0.25.4",
"@esbuild/linux-arm64": "^0.25.4",
"@esbuild/linux-x64": "^0.25.4"
},
"overrides": {
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.34.9"
},
"@rollup/rollup-darwin-x64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-linux-x64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-darwin-arm64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-linux-arm64": "npm:@rollup/wasm-node@^4.34.9"
}
}

View File

@@ -0,0 +1 @@
../ui/package.json

View File

@@ -19,7 +19,6 @@ import {
} from "@/src/components/inputBase";
import { FieldLayout } from "./layout";
import Icon from "@/src/components/icon";
import { useContext } from "corvu/dialog";
interface Option {
value: string;
@@ -51,9 +50,6 @@ interface SelectInputpProps {
}
export function SelectInput(props: SelectInputpProps) {
const dialogContext = (dialogContextId?: string) =>
useContext(dialogContextId);
const _id = createUniqueId();
const [reference, setReference] = createSignal<HTMLElement>();

View File

@@ -23,9 +23,37 @@ export type SuccessQuery<T extends OperationNames> = Extract<
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
function isMachine(obj: unknown): obj is Machine {
return (
!!obj &&
typeof obj === "object" &&
typeof (obj as any).name === "string" &&
typeof (obj as any).flake === "object" &&
typeof (obj as any).flake.identifier === "string"
);
}
// Machine type with flake for API calls
interface Machine {
name: string;
flake: {
identifier: string;
};
}
interface BackendOpts {
logging?: { group: string | Machine };
}
interface BackendReturnType<K extends OperationNames> {
result: OperationResponse<K>;
metadata: Record<string, any>;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: BackendOpts,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
@@ -33,19 +61,30 @@ const _callApi = <K extends OperationNames>(
// return a rejected promise
return {
promise: Promise.resolve({
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
}),
op_key: "noop",
};
}
let metadata: BackendOpts | undefined = undefined;
if (backendOpts != undefined) {
metadata = { ...backendOpts };
let group = backendOpts?.logging?.group;
if (group != undefined && isMachine(group)) {
metadata = {
logging: { group: group.flake.identifier + "#" + group.name },
};
}
}
const promise = (
window as unknown as Record<
OperationNames,
@@ -105,10 +144,11 @@ const handleCancel = async <K extends OperationNames>(
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: BackendOpts,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args);
console.log("Calling API", method, args, backendOpts);
const { promise, op_key } = _callApi(method, args);
const { promise, op_key } = _callApi(method, args, backendOpts);
promise.catch((error) => {
toast.custom(
(t) => (
@@ -146,13 +186,14 @@ export const callApi = <K extends OperationNames>(
console.log("Not printing toast because operation was cancelled");
}
if (response.status === "error" && !cancelled) {
const result = response;
if (result.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + response.errors[0].message}
message={"Error: " + result.errors[0].message}
/>
),
{
@@ -162,7 +203,8 @@ export const callApi = <K extends OperationNames>(
} else {
toast.remove(toastId);
}
return response;
return result;
});
return { promise: new_promise, op_key: op_key };
};

View File

@@ -61,7 +61,7 @@ export const ApiTester = () => {
return await callApi(
values.endpoint as keyof API,
JSON.parse(values.payload || "{}"),
);
).promise;
},
staleTime: Infinity,
enabled: false,

View File

@@ -27,5 +27,5 @@
}
.button--dark-active:active {
@apply active:border-secondary-900 active:shadow-button-primary-active;
@apply active:border-secondary-900;
}

View File

@@ -7,5 +7,5 @@
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-button-primary-active;
@apply active:bg-secondary-200 active:text-secondary-900;
}

View File

@@ -27,7 +27,7 @@
}
.button--light-active:active {
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900 active:shadow-button-primary-active;
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900;
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);

View File

@@ -17,7 +17,7 @@ const defaultRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 0,
host_key_check: "strict",
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
@@ -32,7 +32,7 @@ const sampleRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 1,
host_key_check: "ask",
verbose_ssh: false,
ssh_options: {
StrictHostKeyChecking: "no",
@@ -238,7 +238,7 @@ const advancedRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: 2,
host_key_check: "none",
verbose_ssh: true,
ssh_options: {
ConnectTimeout: "10",

View File

@@ -11,13 +11,6 @@ import { Loader } from "@/src/components/v2/Loader/Loader";
import { Button } from "@/src/components/v2/Button/Button";
import Accordion from "@/src/components/accordion";
// Define the HostKeyCheck enum values with proper API mapping
export enum HostKeyCheck {
ASK = 0,
TOFU = 1,
IGNORE = 2,
}
// Export the API types for use in other components
export type { RemoteData, Machine, RemoteDataSource };
@@ -185,40 +178,6 @@ export function RemoteForm(props: RemoteFormProps) {
const [formData, setFormData] = createSignal<RemoteData | null>(null);
const [isSaving, setIsSaving] = createSignal(false);
const hostKeyCheckOptions = [
{ value: "ASK", label: "Ask" },
{ value: "TOFU", label: "TOFU (Trust On First Use)" },
{ value: "IGNORE", label: "Ignore" },
];
// Helper function to convert enum name to numeric value
const getHostKeyCheckValue = (name: string): number => {
switch (name) {
case "ASK":
return HostKeyCheck.ASK;
case "TOFU":
return HostKeyCheck.TOFU;
case "IGNORE":
return HostKeyCheck.IGNORE;
default:
return HostKeyCheck.ASK;
}
};
// Helper function to convert numeric value to enum name
const getHostKeyCheckName = (value: number | undefined): string => {
switch (value) {
case HostKeyCheck.ASK:
return "ASK";
case HostKeyCheck.TOFU:
return "TOFU";
case HostKeyCheck.IGNORE:
return "IGNORE";
default:
return "ASK";
}
};
// Query host data when machine is provided
const hostQuery = useQuery(() => ({
queryKey: [
@@ -241,11 +200,15 @@ export function RemoteForm(props: RemoteFormProps) {
});
}
const result = await callApi("get_host", {
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
}).promise;
const result = await callApi(
"get_host",
{
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
},
{logging: { group: { name: props.machine.name, flake: props.machine.flake } },},
).promise;
if (result.status === "error")
throw new Error("Failed to fetch host data");
@@ -372,16 +335,13 @@ export function RemoteForm(props: RemoteFormProps) {
<SelectInput
label="Host Key Check"
value={getHostKeyCheckName(formData()?.host_key_check)}
options={hostKeyCheckOptions}
selectProps={{
onInput: (e) =>
updateFormData({
host_key_check: getHostKeyCheckValue(
e.currentTarget.value,
) as 0 | 1 | 2 | 3,
}),
}}
value={formData()?.host_key_check || "ask"}
options={[
{ value: "ask", label: "Ask" },
{ value: "none", label: "None" },
{ value: "strict", label: "Strict" },
{ value: "tofu", label: "Trust on First Use" },
]}
disabled={computedDisabled}
helperText="How to handle host key verification"
/>

View File

@@ -0,0 +1,39 @@
import { JSX, Show } from "solid-js";
interface SimpleModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: JSX.Element;
}
export const SimpleModal = (props: SimpleModalProps) => {
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div class="fixed inset-0 bg-black/50" onClick={props.onClose} />
{/* Modal Content */}
<div class="relative mx-4 w-full max-w-md rounded-lg bg-white shadow-lg">
{/* Header */}
<Show when={props.title}>
<div class="flex items-center justify-between border-b p-4">
<h3 class="text-lg font-semibold">{props.title}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600"
onClick={props.onClose}
>
×
</button>
</div>
</Show>
{/* Body */}
<div>{props.children}</div>
</div>
</div>
</Show>
);
};

View File

@@ -125,7 +125,7 @@ export const InputLabel = (props: InputLabelProps) => {
weight="bold"
class="inline-flex gap-1 align-middle !fg-def-1"
classList={{
[cx("!fg-semantic-1")]: !!props.error,
[cx("!text-red-600")]: !!props.error,
}}
aria-invalid={props.error}
>
@@ -185,7 +185,7 @@ export const InputError = (props: InputErrorProps) => {
// @ts-expect-error: Dependent type is to complex to check how it is coupled to the override for now
size="xxs"
weight="medium"
class={cx("col-span-full px-1 !fg-semantic-4", typoClasses)}
class={cx("col-span-full px-1 !text-red-500", typoClasses)}
{...rest}
>
{props.error}

View File

@@ -47,11 +47,14 @@ export const MachineListItem = (props: MachineListItemProps) => {
);
return;
}
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
const target_host = await callApi(
"get_host",
{
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}, {logging: { group: { name, flake: { identifier: active_clan } } }}
).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
@@ -79,7 +82,6 @@ export const MachineListItem = (props: MachineListItemProps) => {
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
target_host: target_host.data!.data,
@@ -108,6 +110,8 @@ export const MachineListItem = (props: MachineListItemProps) => {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}, {
logging: { group: { name, flake: { identifier: active_clan } } },
}).promise;
if (target_host.status == "error") {
@@ -129,7 +133,7 @@ export const MachineListItem = (props: MachineListItemProps) => {
field: "buildHost",
flake: { identifier: active_clan },
name: name,
}).promise;
}, {logging: { group: { name, flake: { identifier: active_clan } } }}).promise;
if (build_host.status == "error") {
console.error("No target host found for the machine");
@@ -150,7 +154,7 @@ export const MachineListItem = (props: MachineListItemProps) => {
},
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
}).promise;
}, {logging: { group: { name, flake: { identifier: active_clan } } }}).promise;
setUpdating(false);
};

View File

@@ -1,134 +0,0 @@
import Dialog from "corvu/dialog";
import { createSignal, JSX } from "solid-js";
import { Button } from "../Button/Button";
import Icon from "../icon";
import cx from "classnames";
interface ModalProps {
open: boolean | undefined;
handleClose: () => void;
title: string;
children: JSX.Element;
class?: string;
}
export const Modal = (props: ModalProps) => {
const [dragging, setDragging] = createSignal(false);
const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 });
let dialogRef: HTMLDivElement;
const handleMouseDown = (e: MouseEvent) => {
setDragging(true);
const rect = dialogRef.getBoundingClientRect();
setStartOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleMouseMove = (e: MouseEvent) => {
if (dragging()) {
let newTop = e.clientY - startOffset().y;
let newLeft = e.clientX - startOffset().x;
if (newTop < 0) {
newTop = 0;
}
if (newLeft < 0) {
newLeft = 0;
}
dialogRef.style.top = `${newTop}px`;
dialogRef.style.left = `${newLeft}px`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
}
};
const handleMouseUp = () => setDragging(false);
return (
<Dialog open={props.open} trapFocus={true}>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-50 bg-black/50"
onMouseMove={handleMouseMove}
/>
<Dialog.Content
class={cx(
"overflow-hidden absolute left-1/3 top-1/3 z-50 min-w-[560px] rounded-md border border-def-4 focus-visible:outline-none",
props.class,
)}
classList={{
"!cursor-grabbing": dragging(),
[cx("scale-[101%] transition-transform")]: dragging(),
}}
ref={(el) => {
dialogRef = el;
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseDown={(e: MouseEvent) => {
e.stopPropagation(); // Prevent backdrop drag conflict
}}
onClick={(e: MouseEvent) => e.stopPropagation()} // Prevent backdrop click closing
>
<Dialog.Label
as="div"
class="flex w-full justify-center border-b-2 px-4 py-2 align-middle bg-def-3 border-def-4"
onMouseDown={handleMouseDown}
>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<span class="mx-2 select-none whitespace-nowrap">
{props.title}
</span>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<div class="absolute right-1 top-2 pl-1 bg-def-3">
<Button
onMouseDown={(e) => e.stopPropagation()}
tabIndex={-1}
class="size-4"
variant="ghost"
onClick={() => props.handleClose()}
size="s"
startIcon={<Icon icon={"Close"} />}
/>
</div>
</Dialog.Label>
<Dialog.Description
class="flex max-h-[90vh] flex-col overflow-y-hidden bg-def-1"
as="div"
>
{props.children}
</Dialog.Description>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
@import "material-icons/iconfont/filled.css";
/* @import "material-icons/iconfont/filled.css"; */
/* List of icons: https://marella.me/material-icons/demo/ */
/* @import url(./components/Typography/css/typography.css); */

View File

@@ -19,10 +19,12 @@ import { createEffect, createSignal } from "solid-js"; // For, Show might not be
import toast from "solid-toast";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase";
import { Modal } from "@/src/components/modal";
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
import Accordion from "@/src/components/accordion";
import { SimpleModal } from "@/src/components/SimpleModal";
// Import the new generic component
import {
FileSelectorField,
@@ -192,12 +194,11 @@ export const Flash = () => {
return (
<>
<Header title="Flash installer" />
<Modal
<SimpleModal
open={confirmOpen() || isFlashing()}
handleClose={() => !isFlashing() && setConfirmOpen(false)}
onClose={() => !isFlashing() && setConfirmOpen(false)}
title="Confirm"
>
{/* ... Modal content as before ... */}
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2">
<Typography
@@ -230,7 +231,7 @@ export const Flash = () => {
</Button>
</div>
</div>
</Modal>
</SimpleModal>
<div class="w-full self-stretch p-8">
<Form
onSubmit={handleSubmit}

View File

@@ -125,7 +125,6 @@ export function InstallMachine(props: InstallMachineProps) {
machine: {
name: props.name,
flake: { identifier: curr_uri },
private_key: values.sshKey?.name,
},
},
target_host: targetHostResponse.data.data,

View File

@@ -55,7 +55,7 @@ export function MachineForm(props: MachineFormProps) {
...values.machine,
tags: Array.from(values.machine.tags || detailed.machine.tags || []),
},
}).promise;
} ).promise;
await queryClient.invalidateQueries({
queryKey: [
@@ -80,7 +80,7 @@ export function MachineForm(props: MachineFormProps) {
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
}).promise;
}, {logging: {group: { name: machine_name, flake: {identifier: base_dir} }}}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
@@ -118,7 +118,8 @@ export function MachineForm(props: MachineFormProps) {
flake: {
identifier: curr_uri,
},
}).promise;
}, {logging: { group: { name: machine, flake: { identifier: curr_uri } } }}
).promise;
if (target.status === "error") {
toast.error("Failed to get target host");
@@ -143,7 +144,7 @@ export function MachineForm(props: MachineFormProps) {
...target_host,
},
build_host: null,
}).promise.finally(() => {
}, {logging: { group: { name: machine, flake: { identifier: curr_uri } } }}).promise.finally(() => {
setIsUpdating(false);
});
};

View File

@@ -44,7 +44,7 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
command_prefix: "sudo",
port: 22,
forward_agent: false,
host_key_check: 1, // 0 = ASK
host_key_check: "ask", // 0 = ASK
verbose_ssh: false,
ssh_options: {},
tor_socks: false,

View File

@@ -153,10 +153,10 @@ export const VarsStep = (props: VarsStepProps) => {
base_dir: props.dir,
machine_name: props.machine_id,
full_closure: props.fullClosure,
}).promise;
}, {logging: {group: { name: props.machine_id, flake: {identifier: props.dir} }}}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
},
}));
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {

View File

@@ -8,6 +8,7 @@ import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
import { makePersisted } from "@solid-primitives/storage";
import { useClanContext } from "@/src/contexts/clan";
import { debug } from "console";
type MachinesModel = Extract<
OperationResponse<"list_machines">,
@@ -38,6 +39,7 @@ export const MachineListView: Component = () => {
},
}).promise;
console.log("response", response);
if (response.status === "error") {
console.error("Failed to fetch data");
} else {

View File

@@ -1,26 +1,19 @@
import { API, Error as ApiError } from "@/api/API";
import { API } from "@/api/API";
import { Schema as Inventory } from "@/api/Inventory";
import { toast } from "solid-toast";
import {
ErrorToastComponent,
CancelToastComponent,
} from "@/src/components/toast";
type OperationNames = keyof API;
type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
type ApiEnvelope<T> =
| {
status: "success";
data: T;
op_key: string;
}
| ApiError;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;
type ClanService<T extends ServiceNames> = Services[T];
type ClanServiceInstance<T extends ServiceNames> = NonNullable<
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
@@ -28,51 +21,83 @@ export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
type ClanOperations = Record<OperationNames, (str: string) => void>;
interface GtkResponse<T> {
result: T;
op_key: string;
function isMachine(obj: unknown): obj is Machine {
return (
!!obj &&
typeof obj === "object" &&
typeof (obj as Machine).name === "string" &&
typeof (obj as Machine).flake === "object" &&
typeof (obj as Machine).flake.identifier === "string"
);
}
// Machine type with flake for API calls
interface Machine {
name: string;
flake: {
identifier: string;
};
}
interface BackendOpts {
logging?: { group: string | Machine };
}
interface BackendReturnType<K extends OperationNames> {
result: OperationResponse<K>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: Record<string, any>;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
backendOpts?: BackendOpts,
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
// return a rejected promise
return {
promise: Promise.resolve({
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
result: {
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
},
metadata: {},
}),
op_key: "noop",
};
}
let metadata: BackendOpts | undefined = undefined;
if (backendOpts != undefined) {
metadata = { ...backendOpts };
const group = backendOpts?.logging?.group;
if (group != undefined && isMachine(group)) {
metadata = {
logging: { group: group.flake.identifier + "#" + group.name },
};
}
}
const promise = (
window as unknown as Record<
OperationNames,
(
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
metadata?: BackendOpts,
) => Promise<BackendReturnType<OperationNames>>
>
)[method](args) as Promise<OperationResponse<K>>;
)[method](args, metadata) as Promise<BackendReturnType<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (promise as any)._webviewMessageId as string;
@@ -82,7 +107,7 @@ const _callApi = <K extends OperationNames>(
const handleCancel = async <K extends OperationNames>(
ops_key: string,
orig_task: Promise<OperationResponse<K>>,
orig_task: Promise<BackendReturnType<K>>,
) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
@@ -102,7 +127,7 @@ const handleCancel = async <K extends OperationNames>(
});
const resp = await promise;
if (resp.status === "error") {
if (resp.result.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
@@ -124,10 +149,11 @@ const handleCancel = async <K extends OperationNames>(
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: BackendOpts,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args);
const { promise, op_key } = _callApi(method, args);
const { promise, op_key } = _callApi(method, args, backendOpts);
promise.catch((error) => {
toast.custom(
(t) => (
@@ -165,13 +191,14 @@ export const callApi = <K extends OperationNames>(
console.log("Not printing toast because operation was cancelled");
}
if (response.status === "error" && !cancelled) {
const result = response.result;
if (result.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + response.errors[0].message}
message={"Error: " + result.errors[0].message}
/>
),
{
@@ -181,7 +208,8 @@ export const callApi = <K extends OperationNames>(
} else {
toast.remove(toastId);
}
return response;
return result;
});
return { promise: new_promise, op_key: op_key };
};