UI/iwd: wifi machine module
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
3
pkgs/webview-ui/app/src/routes/machines/index.ts
Normal file
3
pkgs/webview-ui/app/src/routes/machines/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./details";
|
||||||
|
export * from "./create";
|
||||||
|
export * from "./list";
|
||||||
Reference in New Issue
Block a user