Merge pull request 'UI: improve welcome workflows' (#1975) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-08-26 15:04:38 +00:00
7 changed files with 288 additions and 262 deletions

View File

@@ -0,0 +1,10 @@
import { useNavigate } from "@solidjs/router";
export const BackButton = () => {
const navigate = useNavigate();
return (
<button class="btn btn-square btn-ghost" onClick={() => navigate(-1)}>
<span class="material-icons ">arrow_back_ios</span>
</button>
);
};

View File

@@ -3,7 +3,7 @@ import { callApi, SuccessData } from "../api";
import { Menu } from "./Menu"; import { Menu } from "./Menu";
import { activeURI } from "../App"; import { activeURI } from "../App";
import toast from "solid-toast"; import toast from "solid-toast";
import { useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
type MachineDetails = SuccessData<"list_inventory_machines">["data"][string]; type MachineDetails = SuccessData<"list_inventory_machines">["data"][string];
@@ -116,14 +116,16 @@ export const MachineListItem = (props: MachineListItemProps) => {
</figure> </figure>
<div class="card-body flex-row justify-between "> <div class="card-body flex-row justify-between ">
<div class="flex flex-col"> <div class="flex flex-col">
<h2 <A href={`/machines/${name}`}>
class="card-title" <h2
classList={{ class="card-title underline"
"text-neutral-500": nixOnly, classList={{
}} "text-neutral-500": nixOnly,
> }}
{name} >
</h2> {name}
</h2>
</A>
<div class="text-slate-600"> <div class="text-slate-600">
<Show when={info}>{(d) => d()?.description}</Show> <Show when={info}>{(d) => d()?.description}</Show>
</div> </div>

View File

@@ -7,7 +7,9 @@ import {
SubmitHandler, SubmitHandler,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import toast from "solid-toast"; import toast from "solid-toast";
import { setActiveURI } from "@/src/App"; import { setActiveURI, setClanList } from "@/src/App";
import { TextInput } from "@/src/components/TextInput";
import { useNavigate } from "@solidjs/router";
type CreateForm = Meta & { type CreateForm = Meta & {
template: string; template: string;
@@ -21,10 +23,10 @@ export const ClanForm = () => {
template: "minimal", template: "minimal",
}, },
}); });
const navigate = useNavigate();
const handleSubmit: SubmitHandler<CreateForm> = async (values, event) => { const handleSubmit: SubmitHandler<CreateForm> = async (values, event) => {
const { template, ...meta } = values; const { template, ...meta } = values;
const response = await callApi("open_file", { const response = await callApi("open_file", {
file_request: { mode: "save" }, file_request: { mode: "save" },
}); });
@@ -39,29 +41,31 @@ export const ClanForm = () => {
return; return;
} }
await toast.promise( const loading_toast = toast.loading("Creating Clan....");
(async () => { const r = await callApi("create_clan", {
await callApi("create_clan", { options: {
options: { directory: target_dir[0],
directory: target_dir[0], template,
template, initial: {
initial: { meta,
meta, services: {},
services: {}, machines: {},
machines: {}, },
},
},
});
setActiveURI(target_dir[0]);
// setRoute("machines");
})(),
{
loading: "Creating clan...",
success: "Clan Successfully Created",
error: "Failed to create clan",
}, },
); });
reset(formStore); toast.dismiss(loading_toast);
if (r.status === "error") {
toast.error("Failed to create clan");
return;
}
if (r.status === "success") {
toast.success("Clan Successfully Created");
setActiveURI(target_dir[0]);
setClanList((list) => [...list, target_dir[0]]);
navigate("/machines");
reset(formStore);
}
}; };
return ( return (
@@ -108,7 +112,7 @@ export const ClanForm = () => {
{...props} {...props}
disabled={formStore.submitting} disabled={formStore.submitting}
required required
placeholder="Clan Name" placeholder="Give your Clan a legendary name"
class="input input-bordered" class="input input-bordered"
classList={{ "input-error": !!field.error }} classList={{ "input-error": !!field.error }}
value={field.value} value={field.value}
@@ -133,7 +137,7 @@ export const ClanForm = () => {
disabled={formStore.submitting} disabled={formStore.submitting}
required required
type="text" type="text"
placeholder="Some words about your clan" placeholder="Tell us what makes your Clan legendary"
class="input input-bordered" class="input input-bordered"
classList={{ "input-error": !!field.error }} classList={{ "input-error": !!field.error }}
value={field.value || ""} value={field.value || ""}
@@ -152,23 +156,20 @@ export const ClanForm = () => {
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title link font-medium ">Advanced</div> <div class="collapse-title link font-medium ">Advanced</div>
<div class="collapse-content"> <div class="collapse-content">
<label class="form-control w-full"> <TextInput
<div class="label "> adornment={{
<span class="label-text after:ml-0.5 after:text-primary after:content-['*']"> content: (
Template to use <span class="-mr-1 text-neutral-500">clan-core #</span>
</span> ),
</div> position: "start",
<input }}
{...props} formStore={formStore}
required inputProps={props}
disabled={formStore.submitting} label="Template to use"
type="text" value={field.value ?? ""}
placeholder="Template to use" error={field.error}
class="input input-bordered" required
classList={{ "input-error": !!field.error }} />
value={field.value}
/>
</label>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import { callApi, SuccessData } from "@/src/api"; import { callApi, SuccessData } from "@/src/api";
import { activeURI } from "@/src/App"; import { activeURI } from "@/src/App";
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";
@@ -420,159 +421,163 @@ const MachineForm = (props: MachineDetailsProps) => {
} }
}; };
return ( return (
<div class="m-2 w-full max-w-xl"> <div class="flex w-full justify-center">
<Form onSubmit={handleSubmit}> <div class="m-2 w-full max-w-xl">
<div class="flex w-full justify-center p-2"> <Form onSubmit={handleSubmit}>
<div <div class="flex w-full justify-center p-2">
class="avatar placeholder" <div
classList={{ class="avatar placeholder"
online: onlineStatusQuery.data === "Online", classList={{
offline: onlineStatusQuery.data === "Offline", online: onlineStatusQuery.data === "Online",
}} offline: onlineStatusQuery.data === "Offline",
> }}
<div class="w-24 rounded-full bg-neutral text-neutral-content"> >
<Show <div class="w-24 rounded-full bg-neutral text-neutral-content">
when={onlineStatusQuery.isFetching} <Show
fallback={<span class="material-icons text-4xl">devices</span>} when={onlineStatusQuery.isFetching}
> fallback={
<span class="loading loading-bars loading-sm justify-self-end"></span> <span class="material-icons text-4xl">devices</span>
</Show> }
>
<span class="loading loading-bars loading-sm justify-self-end"></span>
</Show>
</div>
</div> </div>
</div> </div>
</div> <div class="my-2 w-full text-2xl">Details</div>
<div class="my-2 w-full text-2xl">Details</div> <Field name="name">
<Field name="name"> {(field, props) => (
{(field, props) => ( <TextInput
<TextInput formStore={formStore}
formStore={formStore} inputProps={props}
inputProps={props} label="Name"
label="Name" value={field.value ?? ""}
value={field.value ?? ""} error={field.error}
error={field.error} class="col-span-2"
class="col-span-2" required
required />
/> )}
)} </Field>
</Field> <Field name="description">
<Field name="description"> {(field, props) => (
{(field, props) => ( <TextInput
<TextInput formStore={formStore}
formStore={formStore} inputProps={props}
inputProps={props} label="Description"
label="Description" value={field.value ?? ""}
value={field.value ?? ""} error={field.error}
error={field.error} class="col-span-2"
class="col-span-2" required
required />
/> )}
)} </Field>
</Field>
<div class="collapse collapse-arrow" tabindex="0"> <div class="collapse collapse-arrow" tabindex="0">
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title link px-0 text-xl "> <div class="collapse-title link px-0 text-xl ">
Connection Settings Connection Settings
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<Field name="targetHost"> <Field name="targetHost">
{(field, props) => ( {(field, props) => (
<TextInput <TextInput
formStore={formStore} formStore={formStore}
inputProps={props} inputProps={props}
label="Target Host" label="Target Host"
value={field.value ?? ""} value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="sshKey" type="File">
{(field, props) => (
<>
<FileInput
{...props}
onClick={async (event) => {
event.preventDefault(); // Prevent the native file dialog from opening
const input = event.target;
const files = await selectSshKeys();
// Set the files
Object.defineProperty(input, "files", {
value: files,
writable: true,
});
// Define the files property on the input element
const changeEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(changeEvent);
}}
placeholder={"When empty the default key(s) will be used"}
value={field.value}
error={field.error} error={field.error}
helperText="Provide the SSH key used to connect to the machine" class="col-span-2"
label="SSH Key" required
/> />
</> )}
)} </Field>
</Field> <Field name="sshKey" type="File">
{(field, props) => (
<>
<FileInput
{...props}
onClick={async (event) => {
event.preventDefault(); // Prevent the native file dialog from opening
const input = event.target;
const files = await selectSshKeys();
// Set the files
Object.defineProperty(input, "files", {
value: files,
writable: true,
});
// Define the files property on the input element
const changeEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(changeEvent);
}}
placeholder={"When empty the default key(s) will be used"}
value={field.value}
error={field.error}
helperText="Provide the SSH key used to connect to the machine"
label="SSH Key"
/>
</>
)}
</Field>
</div>
</div> </div>
</div>
<div class="my-2 w-full"> <div class="my-2 w-full">
<button <button
class="btn btn-primary btn-wide" class="btn btn-primary btn-wide"
type="submit" type="submit"
disabled={!formStore.dirty} disabled={!formStore.dirty}
> >
Save Save
</button> </button>
</div> </div>
</Form> </Form>
<div class="my-2 w-full text-2xl">Remote Interactions</div> <div class="my-2 w-full text-2xl">Remote Interactions</div>
<div class="my-2 flex w-full flex-col gap-2"> <div class="my-2 flex w-full flex-col gap-2">
<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.
</span> </span>
<div class="tooltip w-fit" data-tip="Machine must be online"> <div class="tooltip w-fit" data-tip="Machine must be online">
<button <button
class="btn btn-primary btn-sm btn-wide" class="btn btn-primary btn-sm btn-wide"
disabled={!online()} disabled={!online()}
// @ts-expect-error: This string method is not supported by ts // @ts-expect-error: This string method is not supported by ts
onClick="install_modal.showModal()" onClick="install_modal.showModal()"
> >
<span class="material-icons">send_to_mobile</span> <span class="material-icons">send_to_mobile</span>
Install Install
</button> </button>
</div>
<dialog id="install_modal" class="modal backdrop:bg-transparent">
<div class="modal-box w-11/12 max-w-5xl">
<InstallMachine
name={machineName()}
sshKey={sshKey()}
targetHost={getValue(formStore, "targetHost")}
disks={remoteDiskQuery.data?.blockdevices || []}
/>
</div> </div>
</dialog>
<span class="max-w-md text-neutral"> <dialog id="install_modal" class="modal backdrop:bg-transparent">
Update the system if changes should be synced after the installation <div class="modal-box w-11/12 max-w-5xl">
process. <InstallMachine
</span> name={machineName()}
<div class="tooltip w-fit" data-tip="Machine must be online"> sshKey={sshKey()}
<button targetHost={getValue(formStore, "targetHost")}
class="btn btn-primary btn-sm btn-wide" disks={remoteDiskQuery.data?.blockdevices || []}
disabled={!online()} />
onClick={() => handleUpdate()} </div>
> </dialog>
<span class="material-icons">update</span>
Update <span class="max-w-md text-neutral">
</button> Update the system if changes should be synced after the installation
process.
</span>
<div class="tooltip w-fit" data-tip="Machine must be online">
<button
class="btn btn-primary btn-sm btn-wide"
disabled={!online()}
onClick={() => handleUpdate()}
>
<span class="material-icons">update</span>
Update
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -602,7 +607,8 @@ export const MachineDetails = () => {
})); }));
return ( return (
<> <div class="p-2">
<BackButton />
<Show <Show
when={query.data} when={query.data}
fallback={<span class="loading loading-lg"></span>} fallback={<span class="loading loading-lg"></span>}
@@ -615,6 +621,6 @@ export const MachineDetails = () => {
}} }}
/> />
</Show> </Show>
</> </div>
); );
}; };

View File

@@ -59,68 +59,73 @@ export function CreateMachine() {
} }
}; };
return ( return (
<div class="px-1"> <div class="flex w-full justify-center">
<span class="px-2">Create new Machine</span> <div class="mt-4 w-full max-w-3xl self-stretch px-2">
<Form onSubmit={handleSubmit}> <span class="px-2">Create new Machine</span>
<Field <Form onSubmit={handleSubmit}>
name="machine.name" <Field
validate={[required("This field is required")]} name="machine.name"
> validate={[required("This field is required")]}
{(field, props) => ( >
<TextInput {(field, props) => (
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"name"}
error={field.error}
required
/>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"description"}
error={field.error}
/>
)}
</Field>
<Field name="machine.deploy.targetHost">
{(field, props) => (
<>
<TextInput <TextInput
inputProps={props} inputProps={props}
formStore={formStore} formStore={formStore}
value={`${field.value}`} value={`${field.value}`}
label={"Deployment target"} label={"name"}
error={field.error}
required
/>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"description"}
error={field.error} error={field.error}
/> />
</> )}
)} </Field>
</Field> <Field name="machine.deploy.targetHost">
<button {(field, props) => (
class="btn btn-error float-right"
type="submit"
classList={{
"btn-disabled": formStore.submitting,
}}
>
<Switch
fallback={
<> <>
<span class="loading loading-spinner loading-sm"></span>Creating <TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"Deployment target"}
error={field.error}
/>
</> </>
} )}
> </Field>
<Match when={!formStore.submitting}> <div class="mt-12 flex justify-end">
<span class="material-icons">add</span>Create <button
</Match> class="btn btn-primary"
</Switch> type="submit"
</button> classList={{
</Form> "btn-disabled": formStore.submitting,
}}
>
<Switch
fallback={
<>
<span class="loading loading-spinner loading-sm"></span>
Creating
</>
}
>
<Match when={!formStore.submitting}>
<span class="material-icons">add</span>Create
</Match>
</Switch>
</button>
</div>
</Form>
</div>
</div> </div>
); );
} }

View File

@@ -113,7 +113,17 @@ export const MachineListView: Component = () => {
nixOnlyMachines()?.length === 0 nixOnlyMachines()?.length === 0
} }
> >
No machines found <div class="mt-8 flex w-full flex-col items-center justify-center gap-2">
<span class="text-lg text-neutral">
No machines defined yet. Click below to define one.
</span>
<button
class="btn btn-square btn-ghost size-28 overflow-hidden p-2"
onClick={() => navigate("/machines/create")}
>
<span class="material-icons text-6xl font-light">add</span>
</button>
</div>
</Match> </Match>
<Match when={!inventoryQuery.isLoading}> <Match when={!inventoryQuery.isLoading}>
<ul> <ul>

View File

@@ -13,7 +13,7 @@ export const Welcome = () => {
<div class="flex flex-col items-start gap-2"> <div class="flex flex-col items-start gap-2">
<button <button
class="btn btn-primary w-full" class="btn btn-primary w-full"
// onClick={() => setRoute("createClan")} onClick={() => navigate("/clan/create")}
> >
Build your own Build your own
</button> </button>
@@ -29,14 +29,6 @@ export const Welcome = () => {
> >
Or select folder Or select folder
</button> </button>
<button
class="link w-full text-right text-secondary"
onClick={async () => {
setClanList((c) => [...c, "debug"]);
}}
>
Skip (Debug)
</button>
</div> </div>
</div> </div>
</div> </div>