clan-app: Finish flash view. clan-cli: Flash cli now verifies if language and keymap are valid.

This commit is contained in:
Qubasa
2024-08-02 17:51:45 +02:00
parent f2e697f3e4
commit 3e9ebbc90f
27 changed files with 556 additions and 202 deletions

View File

@@ -1,4 +1,4 @@
import { createEffect, createSignal, type Component } from "solid-js";
import { type Component, createEffect, createSignal } from "solid-js";
import { Layout } from "./layout/layout";
import { Route, Router } from "./Routes";
import { Toaster } from "solid-toast";
@@ -18,7 +18,7 @@ const [activeURI, setActiveURI] = makePersisted(
{
name: "activeURI",
storage: localStorage,
}
},
);
export { activeURI, setActiveURI };

View File

@@ -58,11 +58,11 @@ const registry: ObserverRegistry = operationNames.reduce(
...acc,
[opName]: {},
}),
{} as ObserverRegistry
{} as ObserverRegistry,
);
function createFunctions<K extends OperationNames>(
operationName: K
operationName: K,
): {
dispatch: (args: OperationArgs<K>) => void;
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
@@ -104,7 +104,7 @@ function download(filename: string, text: string) {
const element = document.createElement("a");
element.setAttribute(
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text)
"data:text/plain;charset=utf-8," + encodeURIComponent(text),
);
element.setAttribute("download", filename);
@@ -118,7 +118,7 @@ function download(filename: string, text: string) {
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>
args: OperationArgs<K>,
) => {
return new Promise<OperationResponse<K>>((resolve, reject) => {
const id = nanoid();
@@ -134,21 +134,19 @@ export const callApi = <K extends OperationNames>(
});
};
const deserialize =
<T>(fn: (response: T) => void) =>
(str: string) => {
try {
const r = JSON.parse(str) as T;
console.log("Received: ", r);
fn(r);
} catch (e) {
console.log("Error parsing JSON: ", e);
window.localStorage.setItem("error", str);
console.error(str);
console.error("See localStorage 'error'");
alert(`Error parsing JSON: ${e}`);
}
};
const deserialize = <T>(fn: (response: T) => void) => (str: string) => {
try {
const r = JSON.parse(str) as T;
console.log("Received: ", r);
fn(r);
} catch (e) {
console.log("Error parsing JSON: ", e);
window.localStorage.setItem("error", str);
console.error(str);
console.error("See localStorage 'error'");
alert(`Error parsing JSON: ${e}`);
}
};
// Create the API object

View File

@@ -1,5 +1,5 @@
import { Match, Show, Switch, createSignal } from "solid-js";
import { ErrorData, SuccessData, pyApi } from "../api";
import { createSignal, Match, Show, Switch } from "solid-js";
import { ErrorData, pyApi, SuccessData } from "../api";
type MachineDetails = SuccessData<"list_machines">["data"][string];
@@ -94,21 +94,18 @@ export const MachineListItem = (props: MachineListItemProps) => {
<div class="flex flex-row flex-wrap gap-4 py-2">
<div class="badge badge-primary flex flex-row gap-1 py-4 align-middle">
<span>System:</span>
{hwInfo()[name]?.system ? (
<span class="text-primary">{hwInfo()[name]?.system}</span>
) : (
<span class="text-warning">Not set</span>
)}
{hwInfo()[name]?.system
? <span class="text-primary">{hwInfo()[name]?.system}</span>
: <span class="text-warning">Not set</span>}
</div>
<div class="badge badge-ghost flex flex-row gap-1 py-4 align-middle">
<span>Target Host:</span>
{deploymentInfo()[name] ? (
<span class="text-primary">{deploymentInfo()[name]}</span>
) : (
<span class="text-warning">Not set</span>
)}
{/* <Show
{deploymentInfo()[name]
? <span class="text-primary">{deploymentInfo()[name]}</span>
: <span class="text-warning">Not set</span>}
{
/* <Show
when={deploymentInfo()[name]}
fallback={
<Switch fallback={<div class="skeleton h-8 w-full"></div>}>
@@ -119,7 +116,8 @@ export const MachineListItem = (props: MachineListItemProps) => {
}
>
{(i) => + i()}
</Show> */}
</Show> */
}
</div>
</div>
{/* Show only the first error at the bottom */}

View File

@@ -13,9 +13,9 @@ export interface UseFloatingOptions<
whileElementsMounted?: (
reference: R,
floating: F,
update: () => void
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => void | (() => void);
update: () => void,
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
void | (() => void);
}
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
@@ -30,7 +30,7 @@ export interface UseFloatingResult extends UseFloatingState {
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
reference: () => R | undefined | null,
floating: () => F | undefined | null,
options?: UseFloatingOptions<R, F>
options?: UseFloatingOptions<R, F>,
): UseFloatingResult {
const placement = () => options?.placement ?? "bottom";
const strategy = () => options?.strategy ?? "absolute";
@@ -77,7 +77,7 @@ export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
},
(err) => {
setError(err);
}
},
);
}
}
@@ -95,7 +95,7 @@ export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
const cleanup = options.whileElementsMounted(
currentReference,
currentFloating,
update
update,
);
if (cleanup) {

View File

@@ -13,7 +13,7 @@ window.clan = window.clan || {};
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?"
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
);
}
@@ -30,5 +30,5 @@ render(
</QueryClientProvider>
),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
root!
root!,
);

View File

@@ -32,7 +32,8 @@ export const Layout: Component<LayoutProps> = (props) => {
for="toplevel-drawer"
aria-label="close sidebar"
class="drawer-overlay"
></label>
>
</label>
<Sidebar route={route} setRoute={setRoute} />
</div>
</div>

View File

@@ -27,35 +27,35 @@ export const BlockDevicesView: Component = () => {
</button>
</div>
<div class="flex max-w-screen-lg flex-col gap-4">
{isFetching ? (
<span class="loading loading-bars"></span>
) : (
<Show when={devices}>
{(devices) => (
<For each={devices().blockdevices}>
{(device) => (
<div class="stats shadow">
<div class="stat w-28 py-8">
<div class="stat-title">Name</div>
<div class="stat-value">
{" "}
<span class="material-icons">storage</span>{" "}
{device.name}
{isFetching
? <span class="loading loading-bars"></span>
: (
<Show when={devices}>
{(devices) => (
<For each={devices().blockdevices}>
{(device) => (
<div class="stats shadow">
<div class="stat w-28 py-8">
<div class="stat-title">Name</div>
<div class="stat-value">
{" "}
<span class="material-icons">storage</span>{" "}
{device.name}
</div>
<div class="stat-desc"></div>
</div>
<div class="stat-desc"></div>
</div>
<div class="stat w-28">
<div class="stat-title">Size</div>
<div class="stat-value">{device.size}</div>
<div class="stat-desc"></div>
<div class="stat w-28">
<div class="stat-title">Size</div>
<div class="stat-value">{device.size}</div>
<div class="stat-desc"></div>
</div>
</div>
</div>
)}
</For>
)}
</Show>
)}
)}
</For>
)}
</Show>
)}
</div>
</div>
);

View File

@@ -1,10 +1,10 @@
import { OperationResponse, callApi, pyApi } from "@/src/api";
import { callApi, OperationResponse, pyApi } from "@/src/api";
import { Show } from "solid-js";
import {
SubmitHandler,
createForm,
required,
reset,
SubmitHandler,
} from "@modular-forms/solid";
import toast from "solid-toast";
import { setActiveURI, setRoute } from "@/src/App";
@@ -43,7 +43,7 @@ export const ClanForm = () => {
(async () => {
await callApi("create_clan", {
options: {
directory: target_dir,
directory: target_dir[0],
template_url,
initial: {
meta,
@@ -52,14 +52,14 @@ export const ClanForm = () => {
},
},
});
setActiveURI(target_dir);
setActiveURI(target_dir[0]);
setRoute("machines");
})(),
{
loading: "Creating clan...",
success: "Clan Successfully Created",
error: "Failed to create clan",
}
},
);
reset(formStore);
};

View File

@@ -1,10 +1,10 @@
import { OperationResponse, callApi, pyApi } from "@/src/api";
import { Accessor, Show, Switch, Match } from "solid-js";
import { callApi, OperationResponse, pyApi } from "@/src/api";
import { Accessor, Match, Show, Switch } from "solid-js";
import {
SubmitHandler,
createForm,
required,
reset,
SubmitHandler,
} from "@modular-forms/solid";
import toast from "solid-toast";
import { createQuery } from "@tanstack/solid-query";
@@ -65,7 +65,7 @@ export const FinalEditClanForm = (props: FinalEditClanFormProps) => {
loading: "Updating clan...",
success: "Clan Successfully updated",
error: "Failed to update clan",
}
},
);
props.done();
};

View File

@@ -1,24 +1,24 @@
import { route } from "@/src/App";
import { OperationArgs, OperationResponse, callApi, pyApi } from "@/src/api";
import { SubmitHandler, createForm, required } from "@modular-forms/solid";
import { callApi, OperationArgs, OperationResponse, pyApi } from "@/src/api";
import {
createForm,
required,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query";
import { For, createSignal } from "solid-js";
import { createEffect, createSignal, For } from "solid-js";
import { effect } from "solid-js/web";
// type FlashMachineArgs = {
// machine: Omit<OperationArgs<"flash_machine">["machine"], "cached_deployment">;
// } & Omit<Omit<OperationArgs<"flash_machine">, "machine">, "system_config">;
// type FlashMachineArgs = OperationArgs<"flash_machine">;
// type k = keyof FlashMachineArgs;
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type FlashFormValues = {
machine: {
name: string;
devicePath: string;
flake: string;
};
disk: string;
language: string;
keymap: string;
sshKeys: string[];
};
type BlockDevices = Extract<
@@ -28,10 +28,39 @@ type BlockDevices = Extract<
export const Flash = () => {
const [formStore, { Form, Field }] = createForm<FlashFormValues>({});
const [sshKeys, setSshKeys] = createSignal<string[]>([]);
const [isFlashing, setIsFlashing] = createSignal(false);
const selectSshPubkey = async () => {
try {
const loc = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_multiple_files",
filters: { patterns: ["*.pub"] },
initial_folder: "~/.ssh",
},
});
console.log({ loc }, loc.status);
if (loc.status === "success" && loc.data) {
setSshKeys(loc.data);
return loc.data;
}
} catch (e) {
//
}
};
// Create an effect that updates the form when externalUsername changes
createEffect(() => {
const newSshKeys = sshKeys();
if (newSshKeys) {
setValue(formStore, "sshKeys", newSshKeys);
}
});
const {
data: devices,
refetch: loadDevices,
isFetching,
} = createQuery(() => ({
queryKey: ["block_devices"],
@@ -40,20 +69,61 @@ export const Flash = () => {
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
staleTime: 1000 * 60 * 1, // 1 minutes
staleTime: 1000 * 60 * 2, // 1 minutes
}));
const {
data: keymaps,
isFetching: isFetchingKeymaps,
} = createQuery(() => ({
queryKey: ["list_keymaps"],
queryFn: async () => {
const result = await callApi("list_possible_keymaps", {});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
staleTime: 1000 * 60 * 15, // 15 minutes
}));
const {
data: languages,
isFetching: isFetchingLanguages,
} = createQuery(() => ({
queryKey: ["list_languages"],
queryFn: async () => {
const result = await callApi("list_possible_languages", {});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
staleTime: 1000 * 60 * 15, // 15 minutes
}));
const handleSubmit = async (values: FlashFormValues) => {
// TODO: Rework Flash machine API
// Its unusable in its current state
// await callApi("flash_machine", {
// machine: {
// name: "",
// },
// disks: {values.disk },
// dry_run: true,
// });
console.log("submit", values);
setIsFlashing(true);
try {
await callApi("flash_machine", {
machine: {
name: values.machine.devicePath,
flake: {
loc: values.machine.flake,
},
},
mode: "format",
disks: { "main": values.disk },
system_config: {
language: values.language,
keymap: values.keymap,
ssh_keys_path: values.sshKeys,
},
dry_run: false,
write_efi_boot_entries: false,
debug: false,
});
} catch (error) {
console.error("Error submitting form:", error);
} finally {
setIsFlashing(false);
}
};
return (
@@ -70,7 +140,8 @@ export const Flash = () => {
<input
type="text"
class="grow"
placeholder="machine.flake"
//placeholder="machine.flake"
value="git+https://git.clan.lol/clan/clan-core"
required
{...props}
/>
@@ -86,7 +157,7 @@ export const Flash = () => {
)}
</Field>
<Field
name="machine.name"
name="machine.devicePath"
validate={[required("This field is required")]}
>
{(field, props) => (
@@ -96,7 +167,7 @@ export const Flash = () => {
<input
type="text"
class="grow"
placeholder="machine.name"
value="flash-installer"
required
{...props}
/>
@@ -120,13 +191,11 @@ export const Flash = () => {
class="select select-bordered w-full"
{...props}
>
{/* <span class="material-icons">devices</span> */}
<option disabled>Select a disk</option>
<option value="" disabled>Select a disk</option>
<For each={devices?.blockdevices}>
{(device) => (
<option value={device.name}>
{device.name} / {device.size} bytes
<option value={device.path}>
{device.path} -- {device.size} bytes
</option>
)}
</For>
@@ -147,8 +216,106 @@ export const Flash = () => {
</>
)}
</Field>
<button class="btn btn-error" type="submit">
<span class="material-icons">bolt</span>Flash Installer
<Field name="language" validate={[required("This field is required")]}>
{(field, props) => (
<>
<label class="form-control input-bordered flex w-full items-center gap-2">
<select
required
class="select select-bordered w-full"
{...props}
>
<option>en_US.UTF-8</option>
<For each={languages}>
{(language) => (
<option value={language}>
{language}
</option>
)}
</For>
</select>
<div class="label">
{isFetchingLanguages && (
<span class="label-text-alt">
<span class="loading loading-bars">en_US.UTF-8</span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</>
)}
</Field>
<Field name="keymap" validate={[required("This field is required")]}>
{(field, props) => (
<>
<label class="form-control input-bordered flex w-full items-center gap-2">
<select
required
class="select select-bordered w-full"
{...props}
>
<option>en</option>
<For each={keymaps}>
{(keymap) => (
<option value={keymap}>
{keymap}
</option>
)}
</For>
</select>
<div class="label">
{isFetchingKeymaps && (
<span class="label-text
-alt">
<span class="loading loading-bars"></span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</>
)}
</Field>
<Field name="sshKeys" validate={[]} type="string[]">
{(field, props) => (
<>
<label class="input input-bordered flex items-center gap-2">
<span class="material-icons">key</span>
<input
type="text"
class="grow"
placeholder="Select SSH Key"
value={field.value ? field.value.join(", ") : ""}
readOnly
onClick={() => selectSshPubkey()}
required
{...props}
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)}
</Field>
<button class="btn btn-error" type="submit" disabled={isFlashing()}>
{isFlashing()
? <span class="loading loading-spinner"></span>
: <span class="material-icons">bolt</span>}
{isFlashing() ? "Flashing..." : "Flash Installer"}
</button>
</Form>
</div>

View File

@@ -1,9 +1,9 @@
import {
For,
Show,
type Component,
createEffect,
createSignal,
type Component,
For,
Show,
} from "solid-js";
import { route } from "@/src/App";
import { OperationResponse, pyApi } from "@/src/api";

View File

@@ -1,14 +1,14 @@
import {
type Component,
createEffect,
createSignal,
For,
Match,
Show,
Switch,
createEffect,
createSignal,
type Component,
} from "solid-js";
import { activeURI, route, setActiveURI, setRoute } from "@/src/App";
import { OperationResponse, callApi, pyApi } from "@/src/api";
import { callApi, OperationResponse, pyApi } from "@/src/api";
import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem";
@@ -91,7 +91,8 @@ export const MachineListView: Component = () => {
<span class="material-icons ">add</span>
</button>
</div>
{/* <Show when={services()}>
{
/* <Show when={services()}>
{(services) => (
<For each={Object.values(services())}>
{(service) => (
@@ -137,7 +138,8 @@ export const MachineListView: Component = () => {
)}
</For>
)}
</Show> */}
</Show> */
}
<Switch>
<Match when={loading()}>
{/* Loading skeleton */}

View File

@@ -1,16 +1,16 @@
import { callApi } from "@/src/api";
import {
SubmitHandler,
createForm,
required,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import {
activeURI,
setClanList,
setActiveURI,
setRoute,
clanList,
setActiveURI,
setClanList,
setRoute,
} from "@/src/App";
import {
createEffect,
@@ -140,7 +140,7 @@ const ClanDetails = (props: ClanDetailsProps) => {
s.filter((v, idx) => {
if (v == clan_dir) {
setActiveURI(
clanList()[idx - 1] || clanList()[idx + 1] || null
clanList()[idx - 1] || clanList()[idx + 1] || null,
);
return false;
}