UI/iwd: wifi machine module

This commit is contained in:
Johannes Kirschbauer
2024-09-04 15:21:37 +02:00
parent 87c5ded6a2
commit 035344e47c
9 changed files with 293 additions and 133 deletions

View File

@@ -28,14 +28,23 @@ def instance_name(machine_name: str) -> str:
@API.register @API.register
def get_iwd_service(base_url: str, machine_name: str) -> ServiceIwd | None: def get_iwd_service(base_url: str, machine_name: str) -> ServiceIwd:
""" """
Return the admin service of a clan. Return the admin service of a clan.
There is only one admin service. This might be changed in the future There is only one admin service. This might be changed in the future
""" """
inventory = load_inventory_eval(base_url) inventory = load_inventory_eval(base_url)
return inventory.services.iwd.get(instance_name(machine_name)) service_config = inventory.services.iwd.get(instance_name(machine_name))
if service_config:
return service_config
# Empty service
return ServiceIwd(
meta=ServiceMeta(name="wifi_0"),
roles=ServiceIwdRole(default=ServiceIwdRoleDefault(machines=[machine_name])),
config=IwdConfig(networks={}),
)
@dataclass @dataclass

View File

@@ -25,10 +25,6 @@ export function TextInput<T extends FieldValues, R extends ResponseData>(
) { ) {
const value = () => props.value; const value = () => props.value;
createEffect(() => {
console.log("rendering text input", props.value);
});
return ( return (
<label <label
class={cx("form-control w-full", props.class)} class={cx("form-control w-full", props.class)}

View File

@@ -19,3 +19,27 @@ export const registerClan = async () => {
// //
} }
}; };
/**
* Opens the custom file dialog
* Returns a native FileList to allow interaction with the native input type="file"
*/
export const selectSshKeys = async (): Promise<FileList> => {
const dataTransfer = new DataTransfer();
const response = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_file",
initial_folder: "~/.ssh",
},
});
if (response.status === "success" && response.data) {
// Add synthetic files to the DataTransfer object
// FileList cannot be instantiated directly.
response.data.forEach((filename) => {
dataTransfer.items.add(new File([], filename));
});
}
return dataTransfer.files;
};

View File

@@ -4,12 +4,14 @@ import { RouteDefinition, Router } from "@solidjs/router";
import "./index.css"; import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { MachineDetails } from "./routes/machines/[name]/view"; import {
MachineDetails,
MachineListView,
CreateMachine,
} from "./routes/machines";
import { Layout } from "./layout/layout"; import { Layout } from "./layout/layout";
import { MachineListView } from "./routes/machines/view";
import { ClanList, CreateClan, ClanDetails } from "./routes/clans"; import { ClanList, CreateClan, ClanDetails } from "./routes/clans";
import { Flash } from "./routes/flash/view"; import { Flash } from "./routes/flash/view";
import { CreateMachine } from "./routes/machines/create";
import { HostList } from "./routes/hosts/view"; import { HostList } from "./routes/hosts/view";
import { Welcome } from "./routes/welcome"; import { Welcome } from "./routes/welcome";
import { Toaster } from "solid-toast"; import { Toaster } from "solid-toast";

View File

@@ -68,7 +68,7 @@ const EditClanForm = (props: EditClanFormProps) => {
<> <>
<figure class="p-1"> <figure class="p-1">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="text-3xl text-primary">{curr_name()}'s</div> <div class="text-3xl text-primary">{curr_name()}</div>
<div class="text-secondary">Wide settings</div> <div class="text-secondary">Wide settings</div>
</div> </div>
</figure> </figure>
@@ -257,11 +257,9 @@ const AdminModuleForm = (props: AdminModuleFormProps) => {
class="absolute -ml-4 size-full cursor-pointer opacity-0" class="absolute -ml-4 size-full cursor-pointer opacity-0"
type="file" type="file"
onInput={async (e) => { onInput={async (e) => {
console.log(e.target.files);
if (!e.target.files) return; if (!e.target.files) return;
const content = await e.target.files[0].text(); const content = await e.target.files[0].text();
console.log(content);
setValue( setValue(
formStore, formStore,
`allowedKeys.${idx()}.value`, `allowedKeys.${idx()}.value`,

View File

@@ -16,10 +16,6 @@ export function DiskView() {
} }
}, },
})); }));
createEffect(() => {
// Example debugging the data
console.log(query);
});
return ( return (
<div> <div>
<h1>Configure Disk</h1> <h1>Configure Disk</h1>

View File

@@ -4,48 +4,40 @@ import { BackButton } from "@/src/components/BackButton";
import { FileInput } from "@/src/components/FileInput"; import { FileInput } from "@/src/components/FileInput";
import { SelectInput } from "@/src/components/SelectInput"; import { SelectInput } from "@/src/components/SelectInput";
import { TextInput } from "@/src/components/TextInput"; import { TextInput } from "@/src/components/TextInput";
import { createForm, FieldValues, getValue, reset } from "@modular-forms/solid"; import { selectSshKeys } from "@/src/hooks";
import {
createForm,
FieldValues,
getValue,
reset,
setValue,
} from "@modular-forms/solid";
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery, QueryObserver } from "@tanstack/solid-query";
import { createSignal, For, Show, Switch, Match } from "solid-js"; import {
createSignal,
For,
Show,
Switch,
Match,
JSXElement,
createEffect,
createMemo,
} from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions type MachineFormInterface = MachineData & {
type MachineFormInterface = MachineType & {
sshKey?: File; sshKey?: File;
disk?: string; disk?: string;
}; };
type MachineType = SuccessData<"get_inventory_machine_details">; type MachineData = SuccessData<"get_inventory_machine_details">;
type Disks = SuccessQuery<"show_block_devices">["data"]["blockdevices"]; type Disks = SuccessQuery<"show_block_devices">["data"]["blockdevices"];
/** interface InstallForm extends FieldValues {
* Opens the custom file dialog disk?: string;
* Returns a native FileList to allow interaction with the native input type="file" }
*/
const selectSshKeys = async (): Promise<FileList> => {
const dataTransfer = new DataTransfer();
const response = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_file",
initial_folder: "~/.ssh",
},
});
if (response.status === "success" && response.data) {
// Add synthetic files to the DataTransfer object
// FileList cannot be instantiated directly.
response.data.forEach((filename) => {
dataTransfer.items.add(new File([], filename));
});
}
return dataTransfer.files;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type InstallForm = { disk?: string };
interface InstallMachineProps { interface InstallMachineProps {
name?: string; name?: string;
@@ -295,7 +287,11 @@ const InstallMachine = (props: InstallMachineProps) => {
}; };
interface MachineDetailsProps { interface MachineDetailsProps {
initialData: MachineType; initialData: MachineData;
modules: {
name: string;
component: JSXElement;
}[];
} }
const MachineForm = (props: MachineDetailsProps) => { const MachineForm = (props: MachineDetailsProps) => {
const [formStore, { Form, Field }] = const [formStore, { Form, Field }] =
@@ -363,7 +359,13 @@ const MachineForm = (props: MachineDetailsProps) => {
const machine_response = await callApi("set_machine", { const machine_response = await callApi("set_machine", {
flake_url: curr_uri, flake_url: curr_uri,
machine_name: props.initialData.machine.name, machine_name: props.initialData.machine.name,
machine: values.machine, machine: {
...values.machine,
// TODO: Remove this workaround
tags: Array.from(
values.machine.tags || props.initialData.machine.tags || [],
),
},
}); });
if (machine_response.status === "error") { if (machine_response.status === "error") {
toast.error( toast.error(
@@ -416,30 +418,33 @@ const MachineForm = (props: MachineDetailsProps) => {
} }
}; };
return ( return (
<div class="flex w-full justify-center"> <>
<div class="m-2 w-full max-w-xl"> <Form onSubmit={handleSubmit}>
<Form onSubmit={handleSubmit}> <figure>
<div class="flex w-full justify-center p-2"> <div
<div class="avatar placeholder"
class="avatar placeholder" classList={{
classList={{ online: onlineStatusQuery.data === "Online",
online: onlineStatusQuery.data === "Online", offline: onlineStatusQuery.data === "Offline",
offline: onlineStatusQuery.data === "Offline", }}
}} >
> <div class="w-24 rounded-full bg-neutral text-neutral-content">
<div class="w-24 rounded-full bg-neutral text-neutral-content"> <Show
<Show when={onlineStatusQuery.isFetching}
when={onlineStatusQuery.isFetching} fallback={<span class="material-icons text-4xl">devices</span>}
fallback={ >
<span class="material-icons text-4xl">devices</span> <span class="loading loading-bars loading-sm justify-self-end"></span>
} </Show>
>
<span class="loading loading-bars loading-sm justify-self-end"></span>
</Show>
</div>
</div> </div>
</div> </div>
<div class="my-2 w-full text-2xl">Details</div> </figure>
<div class="card-body">
<span class="text-xl text-primary">General</span>
{/*
<Field name="machine.tags" type="string[]">
{(field, props) => field.value}
</Field> */}
<Field name="machine.name"> <Field name="machine.name">
{(field, props) => ( {(field, props) => (
<TextInput <TextInput
@@ -520,18 +525,34 @@ const MachineForm = (props: MachineDetailsProps) => {
</div> </div>
</div> </div>
<div class="my-2 w-full"> {
<button <div class="card-actions justify-end">
class="btn btn-primary btn-wide" <button
type="submit" class="btn btn-primary"
disabled={!formStore.dirty} type="submit"
> disabled={formStore.submitting || !formStore.dirty}
Save >
</button> Save
</div> </button>
</Form> </div>
<div class="my-2 w-full text-2xl">Remote Interactions</div> }
<div class="my-2 flex w-full flex-col gap-2"> </div>
</Form>
<div class="card-body">
<For each={props.modules}>
{(module) => (
<>
<div class="divider"></div>
<span class="text-xl text-primary">{module.name}</span>
{module.component}
</>
)}
</For>
<div class="divider"></div>
<span class="text-xl text-primary">Actions</span>
<div class="my-4 flex flex-col gap-6">
<span class="max-w-md text-neutral"> <span class="max-w-md text-neutral">
Installs the system for the first time. Used to bootstrap the remote Installs the system for the first time. Used to bootstrap the remote
device. device.
@@ -575,13 +596,15 @@ const MachineForm = (props: MachineDetailsProps) => {
</div> </div>
</div> </div>
</div> </div>
</div> </>
); );
}; };
type WifiData = SuccessData<"get_iwd_service">;
export const MachineDetails = () => { export const MachineDetails = () => {
const params = useParams(); const params = useParams();
const query = createQuery(() => ({ const genericQuery = createQuery(() => ({
queryKey: [ queryKey: [
activeURI(), activeURI(),
"machine", "machine",
@@ -601,19 +624,61 @@ export const MachineDetails = () => {
}, },
})); }));
const wifiQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", params.id, "get_iwd_service"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("get_iwd_service", {
base_url: curr,
machine_name: params.id,
});
if (result.status === "error") throw new Error("Failed to fetch data");
return Object.entries(result.data?.config?.networks || {}).map(
([name, value]) => ({ name, ssid: value.ssid }),
);
}
},
}));
return ( return (
<div class="p-2"> <div class="card">
<BackButton /> <BackButton />
<Show <Show
when={query.data} when={genericQuery.data}
fallback={<span class="loading loading-lg"></span>} fallback={<span class="loading loading-lg"></span>}
> >
{(data) => ( {(data) => (
<> <>
<MachineForm initialData={data()} /> <MachineForm
<MachineWifi initialData={data()}
base_url={activeURI() || ""} modules={[
machine_name={data().machine.name} {
component: (
<Show
when={!wifiQuery.isLoading}
fallback={
<div>
<span class="loading loading-lg"></span>
</div>
}
>
<Switch>
<Match when={wifiQuery.data}>
{(d) => (
<WifiModule
initialData={d()}
base_url={activeURI() || ""}
machine_name={data().machine.name}
/>
)}
</Match>
</Switch>
</Show>
),
name: "Wifi",
},
]}
/> />
</> </>
)} )}
@@ -622,61 +687,128 @@ export const MachineDetails = () => {
); );
}; };
interface Wifi extends FieldValues {
name: string;
ssid?: string;
password?: string;
}
interface WifiForm extends FieldValues { interface WifiForm extends FieldValues {
ssid: string; networks: Wifi[];
password: string;
} }
interface MachineWifiProps { interface MachineWifiProps {
base_url: string; base_url: string;
machine_name: string; machine_name: string;
initialData: Wifi[];
} }
function MachineWifi(props: MachineWifiProps) { function WifiModule(props: MachineWifiProps) {
const [formStore, { Form, Field }] = createForm<WifiForm>(); // You can use formData to initialize your form fields:
// const initialFormState = formData();
const [formStore, { Form, Field }] = createForm<WifiForm>({
initialValues: {
networks: props.initialData,
},
});
const [nets, setNets] = createSignal<1[]>(
new Array(props.initialData.length || 1).fill(1),
);
const handleSubmit = async (values: WifiForm) => { const handleSubmit = async (values: WifiForm) => {
console.log("submitting", values); const networks = values.networks
.filter((i) => i.ssid)
.reduce(
(acc, curr) => ({
...acc,
[curr.ssid || ""]: { ssid: curr.ssid, password: curr.password },
}),
{},
);
console.log("submitting", values, networks);
const r = await callApi("set_iwd_service_for_machine", { const r = await callApi("set_iwd_service_for_machine", {
base_url: props.base_url, base_url: props.base_url,
machine_name: props.machine_name, machine_name: props.machine_name,
networks: { networks: networks,
[values.ssid]: { ssid: values.ssid, password: values.password },
},
}); });
if (r.status === "error") {
toast.error("Failed to set wifi");
}
if (r.status === "success") {
toast.success("Wifi set successfully");
}
}; };
return ( return (
<div> <Form onSubmit={handleSubmit}>
<h1>MachineWifi</h1> <span class="text-neutral">Preconfigure wireless networks</span>
<Form onSubmit={handleSubmit}> <For each={nets()}>
<Field name="ssid"> {(_, idx) => (
{(field, props) => ( <div class="flex gap-4">
<TextInput <Field name={`networks.${idx()}.ssid`}>
formStore={formStore} {(field, props) => (
inputProps={props} <TextInput
label="Name" formStore={formStore}
value={field.value ?? ""} inputProps={props}
error={field.error} label="Name"
required value={field.value ?? ""}
/> error={field.error}
)} required
</Field> />
<Field name="password"> )}
{(field, props) => ( </Field>
<TextInput <Field name={`networks.${idx()}.password`}>
formStore={formStore} {(field, props) => (
inputProps={props} <TextInput
label="Password" formStore={formStore}
value={field.value ?? ""} inputProps={props}
error={field.error} label="Password"
type="password" value={field.value ?? ""}
required error={field.error}
/> type="password"
)} required
</Field> />
<button class="btn" type="submit"> )}
<span>Submit</span> </Field>
</button> <button class="btn btn-ghost self-end">
</Form> <span
</div> class="material-icons"
onClick={(e) => {
e.preventDefault();
setNets((c) => c.filter((_, i) => i !== idx()));
setValue(formStore, `networks.${idx()}.ssid`, undefined);
setValue(formStore, `networks.${idx()}.password`, undefined);
}}
>
delete
</span>
</button>
</div>
)}
</For>
<button
class="btn btn-ghost btn-sm my-1 flex items-center justify-center"
onClick={(e) => {
e.preventDefault();
setNets([...nets(), 1]);
}}
>
<span class="material-icons">add</span>
Add Network
</button>
{
<div class="card-actions mt-4 justify-end">
<button
class="btn btn-primary"
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Save
</button>
</div>
}
</Form>
); );
} }

View File

@@ -0,0 +1,3 @@
export * from "./details";
export * from "./create";
export * from "./list";