Merge pull request 'feat(UI): design fixups in {machineList, machineItem, machineDetails, sidebar, sidebarHeader, button, sidebar}' (#3528) from amunsen/clan-core:ui-improvements into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3528
This commit is contained in:
hsjobeki
2025-05-07 14:23:49 +00:00
21 changed files with 3252 additions and 3108 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -142,91 +142,87 @@ export function SelectInput(props: SelectInputpProps) {
</InputLabel> </InputLabel>
} }
field={ field={
<> <InputBase
<InputBase error={!!props.error}
error={!!props.error} disabled={props.disabled}
disabled={props.disabled} required={props.required}
required={props.required} class="!justify-start"
class="!justify-start" divRef={setReference}
divRef={setReference} inputElem={
inputElem={ <button
<button // TODO: Keyboard acessibililty
// TODO: Keyboard acessibililty // Currently the popover only opens with onClick
// Currently the popover only opens with onClick // Options are not selectable with keyboard
// Options are not selectable with keyboard tabIndex={-1}
tabIndex={-1} disabled={props.disabled}
disabled={props.disabled} onClick={() => {
onClick={() => { const popover = document.getElementById(_id);
const popover = document.getElementById(_id); if (popover) {
if (popover) { popover.togglePopover(); // Show or hide the popover
popover.togglePopover(); // Show or hide the popover }
} }}
}} type="button"
type="button" class="flex w-full items-center gap-2"
class="flex w-full items-center gap-2" formnovalidate
formnovalidate // TODO: Use native popover once Webkit supports it within <form>
// TODO: Use native popover once Webkit supports it within <form> // popovertarget={_id}
// popovertarget={_id} // popovertargetaction="toggle"
// popovertargetaction="toggle" >
<Show
when={props.adornment && props.adornment.position === "start"}
> >
{props.adornment?.content}
</Show>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show <Show
when={ when={
props.adornment && props.adornment.position === "start" getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
} }
fallback={props.placeholder}
> >
{props.adornment?.content} <For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</Show> </Show>
{props.inlineLabel} </div>
<div class="flex cursor-default flex-row gap-2"> <Show
<Show when={props.adornment && props.adornment.position === "end"}
when={ >
getValues() && {props.adornment?.content}
getValues.length !== 1 && </Show>
getValues()[0] !== "" <Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
} </button>
fallback={props.placeholder} }
> />
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon icon="CaretDown" class="ml-auto mr-2"></Icon>
</button>
}
/>
</>
} }
/> />

View File

@@ -18,7 +18,7 @@ export const FieldLayout = (props: LayoutProps) => {
class={cx("grid grid-cols-10 items-center", intern.class)} class={cx("grid grid-cols-10 items-center", intern.class)}
{...divProps} {...divProps}
> >
<label class="col-span-5">{props.label}</label> <div class="col-span-5 flex items-center">{props.label}</div>
<div class="col-span-5">{props.field}</div> <div class="col-span-5">{props.field}</div>
{props.error && <span class="col-span-full">{props.error}</span>} {props.error && <span class="col-span-full">{props.error}</span>}
</div> </div>

View File

@@ -96,6 +96,7 @@ export const DynForm = (props: FormProps) => {
return ( return (
<> <>
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
<ModuleForm {...props.formProps} onSubmit={handleSubmit}> <ModuleForm {...props.formProps} onSubmit={handleSubmit}>
{props.components?.before} {props.components?.before}
<SchemaFields <SchemaFields

View File

@@ -98,7 +98,6 @@ export async function set_single_service<T extends keyof Services>(
inventory.services = inventory.services || {}; inventory.services = inventory.services || {};
inventory.services[service_name] = inventory.services[service_name] || {}; inventory.services[service_name] = inventory.services[service_name] || {};
// @ts-expect-error: This doesn't check
inventory.services[service_name][instance_key] = service_config; inventory.services[service_name][instance_key] = service_config;
console.log("saving inventory", inventory); console.log("saving inventory", inventory);
return callApi("set_inventory", { return callApi("set_inventory", {

View File

@@ -1,224 +0,0 @@
import { createSignal, For, Setter, Show } from "solid-js";
import { callApi, SuccessQuery } from "../api";
import { Menu } from "./Menu";
import { activeURI } from "../App";
import toast from "solid-toast";
import { A, useNavigate } from "@solidjs/router";
import { RndThumbnail } from "./noiseThumbnail";
import Icon from "./icon";
import { Filter } from "../routes/machines";
import { Typography } from "./Typography";
import { Button } from "./button";
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info?: MachineDetails;
nixOnly?: boolean;
setFilter: Setter<Filter>;
}
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info, nixOnly } = props;
// Bootstrapping
const [installing, setInstalling] = createSignal<boolean>(false);
// Later only updates
const [updating, setUpdating] = createSignal<boolean>(false);
const navigate = useNavigate();
const handleInstall = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy?.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setInstalling(true);
await toast.promise(
callApi("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
override_target_host: info?.deploy.targetHost,
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
}),
{
loading: "Installing...",
success: "Installed",
error: "Failed to install",
},
);
setInstalling(false);
};
const handleUpdate = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setUpdating(true);
await toast.promise(
callApi("update_machines", {
base_path: active_clan,
machines: [
{
name: name,
deploy: {
targetHost: info?.deploy.targetHost,
},
},
],
}),
{
loading: "Updating...",
success: "Updated",
error: "Failed to update",
},
);
setUpdating(false);
};
return (
<div class="m-2 w-64 rounded-lg border p-3 border-def-2">
<figure class="h-fit rounded-xl border bg-def-2 border-def-5">
<RndThumbnail name={name} width={220} height={120} />
</figure>
<div class="flex-row justify-between gap-4 px-2 pt-2">
<div class="flex flex-col">
<A href={`/machines/${name}`}>
<Typography hierarchy="title" size="m" weight="bold">
{name}
</Typography>
</A>
<div class="flex justify-between text-slate-600">
<div class="flex flex-nowrap">
<span class="h-4">
<Icon icon="Flash" class="h-4" font-size="inherit" />
</span>
<Typography hierarchy="body" size="s" weight="medium">
<Show when={info}>
{(d) => d()?.description || "no description"}
</Show>
</Typography>
</div>
<div class="self-end">
<Menu
popoverid={`menu-${props.name}`}
label={<Icon icon={"More"} />}
>
<ul class="z-[1] w-64 bg-white p-2 shadow ">
<li>
<Button
variant="ghost"
class="w-full"
onClick={() => {
navigate("/machines/" + name);
}}
>
Details
</Button>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || installing(),
}}
>
<Button
variant="ghost"
class="w-full"
onClick={handleInstall}
>
Install
</Button>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || updating(),
}}
>
<Button
variant="ghost"
class="w-full"
onClick={handleUpdate}
>
Update
</Button>
</li>
</ul>
</Menu>
</div>
</div>
{/* <div class="text-slate-600">
<Show when={info}>
{(d) => (
<>
<Show when={d().tags}>
{(tags) => (
<span class="flex gap-1">
<For each={tags()}>
{(tag) => (
<button
type="button"
onClick={() =>
props.setFilter((prev) => {
if (prev.tags.includes(tag)) {
return prev;
}
return {
...prev,
tags: [...prev.tags, tag],
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</span>
)}
</Show>
{d()?.deploy?.targetHost}
</>
)}
</Show>
</div> */}
</div>
</div>
</div>
);
};

View File

@@ -1,7 +1,8 @@
import { createSignal } from "solid-js"; import { createSignal, Show } from "solid-js";
import { Typography } from "@/src/components/Typography"; import { Typography } from "@/src/components/Typography";
import { SidebarFlyout } from "./SidebarFlyout"; import { SidebarFlyout } from "./SidebarFlyout";
import "./css/sidebar.css"; import "./css/sidebar.css";
import Icon from "../icon";
interface SidebarProps { interface SidebarProps {
clanName: string; clanName: string;
@@ -53,8 +54,16 @@ export const SidebarHeader = (props: SidebarProps) => {
return ( return (
<header class="sidebar__header"> <header class="sidebar__header">
<div onClick={handleClick} class="sidebar__header__inner"> <div onClick={handleClick} class="sidebar__header__inner">
<ClanProfile clanName={props.clanName} showFlyout={showFlyout} /> {/* <ClanProfile clanName={props.clanName} showFlyout={showFlyout} /> */}
<ClanTitle clanName={props.clanName} /> <div class="w-full pl-1 text-white">
<ClanTitle clanName={props.clanName} />
</div>
<Show
when={showFlyout}
fallback={<Icon size={12} class="text-white" icon="CaretDown" />}
>
<Icon size={12} class="text-white" icon="CaretDown" />
</Show>
</div> </div>
{showFlyout() && <SidebarFlyout />} {showFlyout() && <SidebarFlyout />}
</header> </header>

View File

@@ -21,7 +21,7 @@ export default function Accordion(props: AccordionProps) {
fallback={ fallback={
<Button <Button
endIcon={<Icon size={12} icon={"CaretDown"} />} endIcon={<Icon size={12} icon={"CaretDown"} />}
variant="light" variant="ghost"
size="s" size="s"
> >
{props.title} {props.title}
@@ -30,7 +30,7 @@ export default function Accordion(props: AccordionProps) {
> >
<Button <Button
endIcon={<Icon size={12} icon={"CaretUp"} />} endIcon={<Icon size={12} icon={"CaretUp"} />}
variant="dark" variant="ghost"
size="s" size="s"
> >
{props.title} {props.title}

View File

@@ -0,0 +1,11 @@
.button--ghost-hover:hover {
@apply hover:bg-secondary-100 hover:text-secondary-900;
}
.button--ghost-focus:focus {
@apply focus:bg-secondary-200 focus:text-secondary-900;
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-inner-primary-active;
}

View File

@@ -1,5 +1,6 @@
@import "./button-light.css"; @import "./button-light.css";
@import "./button-dark.css"; @import "./button-dark.css";
@import "./button-ghost.css";
.button { .button {
@apply inline-flex items-center flex-shrink gap-1 justify-center p-4 font-semibold; @apply inline-flex items-center flex-shrink gap-1 justify-center p-4 font-semibold;

View File

@@ -26,10 +26,9 @@ const variantColors: (
!disabled && "button--light-active", // Active state !disabled && "button--light-active", // Active state
), ),
ghost: cx( ghost: cx(
// "shadow-inner-secondary", !disabled && "button--ghost-hover", // Hover state
!disabled && "hover:bg-secondary-200 hover:text-secondary-900", // Hover state !disabled && "button--ghost-focus", // Focus state
!disabled && "focus:bg-secondary-200 focus:text-secondary-900", // Focus state !disabled && "button--ghost-active", // Active state
!disabled && "button--light-active", // Active state
), ),
}); });

View File

@@ -0,0 +1,76 @@
.machine-item {
@apply col-span-1 flex flex-col items-center;
position: relative;
padding: theme(padding.2);
cursor: pointer;
}
.machine-item__thumb-wrapper {
position: relative;
padding: theme(padding.4);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item__thumb {
@apply rounded-md bg-secondary-100 border border-secondary-200;
position: relative;
z-index: 20;
overflow: hidden;
transition: transform 0.24s ease-in-out;
}
.machine-item__header {
@apply flex flex-col justify-center items-center;
position: relative;
z-index: 20;
transition: transform 0.18s 0.04s ease-in-out;
}
.machine-item__pseudo {
@apply bg-secondary-50;
position: absolute;
z-index: 10;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 1px solid theme(borderColor.secondary.100);
border-radius: theme(borderRadius.md);
transition:
transform 0.16s ease-in-out,
opacity 0.08s ease-in-out;
}
.machine-item:hover {
& .machine-item__pseudo {
transform: scale(1);
opacity: 1;
}
& .machine-item__thumb {
transform: scale(1.02);
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.1),
0 8px 20px rgba(0, 0, 0, 0.15),
0 12px 40px rgba(0, 0, 0, 0.08);
}
& .machine-item__header {
transform: translateY(4px);
}
}
.machine-item:not(:hover) .machine-item__pseudo {
transform: scale(0.94);
opacity: 0;
}

View File

@@ -0,0 +1,135 @@
import { createSignal, For, Setter, Show } from "solid-js";
import { callApi, SuccessQuery } from "../../api";
import { activeURI } from "../../App";
import toast from "solid-toast";
import { A, useNavigate } from "@solidjs/router";
import { RndThumbnail } from "../noiseThumbnail";
import { Filter } from "../../routes/machines";
import { Typography } from "../Typography";
import "./css/index.css";
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info?: MachineDetails;
nixOnly?: boolean;
setFilter: Setter<Filter>;
}
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info, nixOnly } = props;
// Bootstrapping
const [installing, setInstalling] = createSignal<boolean>(false);
// Later only updates
const [updating, setUpdating] = createSignal<boolean>(false);
const navigate = useNavigate();
const handleInstall = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy?.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setInstalling(true);
await toast.promise(
callApi("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
override_target_host: info?.deploy.targetHost,
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
}),
{
loading: "Installing...",
success: "Installed",
error: "Failed to install",
},
);
setInstalling(false);
};
const handleUpdate = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setUpdating(true);
await toast.promise(
callApi("update_machines", {
base_path: active_clan,
machines: [
{
name: name,
deploy: {
targetHost: info?.deploy.targetHost,
},
},
],
}),
{
loading: "Updating...",
success: "Updated",
error: "Failed to update",
},
);
setUpdating(false);
};
return (
<div class="machine-item">
<A href={`/machines/${name}`}>
<div class="machine-item__thumb-wrapper">
<div class="machine-item__thumb">
<RndThumbnail name={name} width={100} height={100} />
</div>
<div class="machine-item__pseudo" />
</div>
<header class="machine-item__header">
<Typography
class="text-center"
hierarchy="body"
size="s"
weight="bold"
color="primary"
>
{name}
</Typography>
</header>
</A>
</div>
);
};

View File

@@ -96,9 +96,102 @@ html {
cursor: pointer; cursor: pointer;
} }
.accordeon__header::-webkit-details-marker { summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::marker {
display: none; display: none;
} }
.accordeon__body { .accordeon__body {
} }
.machine-item-loader {
@apply col-span-1 flex flex-col items-center;
display: flex;
justify-content: center;
position: relative;
padding: theme(padding.2);
border-radius: theme(borderRadius.md);
overflow: hidden;
cursor: pointer;
}
.machine-item-loader__thumb-wrapper {
position: relative;
z-index: 20;
padding: theme(padding.4);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item-loader__thumb {
position: relative;
width: 100px;
height: 100px;
background: theme(backgroundColor.secondary.100);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item-loader__headline {
position: relative;
z-index: 20;
width: 90%;
height: 20px;
background: theme(backgroundColor.secondary.100);
border-radius: theme(borderRadius.sm);
overflow: hidden;
}
.machine-item-loader__cover {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.machine-item-loader__loader {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to right,
transparent 20%,
theme(backgroundColor.secondary.200) 50%,
transparent 80%
);
background-size: 400px 100%;
animation: loader 4s linear infinite;
transition: all 0.56s ease;
}
.machine-item-loader__cover .machine-item-loader__loader {
background: linear-gradient(
to right,
transparent 20%,
theme(backgroundColor.secondary.50) 50%,
transparent 80%
);
}
@keyframes loader {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}

View File

@@ -22,6 +22,8 @@ import toast from "solid-toast";
import { FieldLayout } from "@/src/Form/fields/layout"; import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase"; import { InputLabel } from "@/src/components/inputBase";
import { Modal } from "@/src/components/modal"; import { Modal } from "@/src/components/modal";
import Fieldset from "@/src/Form/fieldset";
import Accordion from "@/src/components/accordion";
interface Wifi extends FieldValues { interface Wifi extends FieldValues {
ssid: string; ssid: string;
@@ -233,16 +235,23 @@ export const Flash = () => {
</div> </div>
</div> </div>
</Modal> </Modal>
<div class="p-4"> <div class="w-full self-stretch p-8">
<Typography tag="p" hierarchy="body" size="default" color="primary"> {/* <Typography tag="p" hierarchy="body" size="default" color="primary">
USB Utility image. USB Utility image.
</Typography> </Typography>
<Typography tag="p" hierarchy="body" size="default" color="secondary"> <Typography tag="p" hierarchy="body" size="default" color="secondary">
Will make bootstrapping new machines easier by providing secure remote Will make bootstrapping new machines easier by providing secure remote
connection to any machine when plugged in. connection to any machine when plugged in.
</Typography> </Typography> */}
<Form onSubmit={handleSubmit}> <Form
<div class="my-4"> onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Fieldset legend="Authorized SSH Keys">
<Typography hierarchy="body" size="s" weight="medium">
Provide your SSH public key. For secure and passwordless SSH
connections.
</Typography>
<Field name="sshKeys" type="File[]"> <Field name="sshKeys" type="File[]">
{(field, props) => ( {(field, props) => (
<> <>
@@ -267,146 +276,72 @@ export const Flash = () => {
}} }}
value={field.value} value={field.value}
error={field.error} error={field.error}
helperText="Provide your SSH public key. For secure and passwordless SSH connections." //helperText="Provide your SSH public key. For secure and passwordless SSH connections."
label="Authorized SSH Keys" //label="Authorized SSH Keys"
multiple multiple
required
/> />
</> </>
)} )}
</Field> </Field>
</div> </Fieldset>
<Fieldset legend="General">
<Field name="disk" validate={[required("This field is required")]}> <Field name="disk" validate={[required("This field is required")]}>
{(field, props) => ( {(field, props) => (
<SelectInput
loading={deviceQuery.isFetching}
selectProps={props}
label="Flash Disk"
labelProps={{
labelAction: (
<Button
disabled={isFlashing()}
class="ml-auto"
variant="ghost"
size="s"
type="button"
startIcon={<Icon icon="Update" />}
onClick={() => deviceQuery.refetch()}
/>
),
}}
value={field.value || ""}
error={field.error}
required
placeholder="Select a drive where the clan-installer will be flashed to"
options={
deviceQuery.data?.blockdevices.map((d) => ({
value: d.path,
label: `${d.path} -- ${d.size} bytes`,
})) || []
}
/>
)}
</Field>
{/* WiFi Networks */}
<div class="my-4 py-2">
<FieldLayout
label={<InputLabel class="mb-4">Networks</InputLabel>}
field={
<> <>
<SelectInput
loading={deviceQuery.isFetching}
selectProps={props}
label="Flash Disk"
labelProps={{
labelAction: (
<Button
disabled={isFlashing()}
class="ml-auto"
variant="ghost"
size="s"
type="button"
startIcon={<Icon icon="Update" />}
onClick={() => deviceQuery.refetch()}
/>
),
}}
value={field.value || ""}
error={field.error}
required
placeholder="Select a drive"
options={
deviceQuery.data?.blockdevices.map((d) => ({
value: d.path,
label: `${d.path} -- ${d.size} bytes`,
})) || []
}
/>
</>
)}
</Field>
</Fieldset>
<Fieldset legend="Network Settings">
<FieldLayout
label={<InputLabel>Networks</InputLabel>}
field={
<div class="flex w-full justify-end">
<Button <Button
type="button" type="button"
size="s" size="s"
variant="light" variant="light"
onClick={addWifiNetwork} onClick={addWifiNetwork}
startIcon={<Icon icon="Plus" />} startIcon={<Icon size={12} icon="Plus" />}
> >
WiFi Network WiFi Network
</Button> </Button>
</> </div>
} }
/> />
<For each={wifiNetworks()}> </Fieldset>
{(network, index) => (
<div class="flex w-full gap-2">
<div class="mb-2 grid w-full grid-cols-6 gap-2 align-middle">
<Field
name={`wifi.${index()}.ssid`}
validate={[required("SSID is required")]}
>
{(field, props) => (
<TextInput
inputProps={props}
label="SSID"
value={field.value ?? ""}
error={field.error}
class="col-span-full "
required
/>
)}
</Field>
<Field
name={`wifi.${index()}.password`}
validate={[required("Password is required")]}
>
{(field, props) => (
<TextInput
class="col-span-full"
inputProps={{
...props,
type: passwordVisibility()[index()]
? "text"
: "password",
}}
label="Password"
value={field.value ?? ""}
error={field.error}
// adornment={{
// position: "end",
// content: (
// <Button
// variant="light"
// type="button"
// class="flex justify-center opacity-70"
// onClick={() =>
// togglePasswordVisibility(index())
// }
// startIcon={
// passwordVisibility()[index()] ? (
// <Icon icon="EyeClose" />
// ) : (
// <Icon icon="EyeOpen" />
// )
// }
// ></Button>
// ),
// }}
required
/>
)}
</Field>
</div>
<Button
type="button"
variant="light"
class="h-10"
size="s"
onClick={() => removeWifiNetwork(index())}
startIcon={<Icon icon="Trash" />}
></Button>
</div>
)}
</For>
</div>
<div class=" " tabindex="0"> <Accordion title="Advanced">
<input type="checkbox" /> <Fieldset>
<div class=" px-0">
<InputLabel class="mb-4">Advanced</InputLabel>
</div>
<div class="">
<Field <Field
name="machine.flake" name="machine.flake"
validate={[required("This field is required")]} validate={[required("This field is required")]}
@@ -508,10 +443,8 @@ export const Flash = () => {
</> </>
)} )}
</Field> </Field>
</div> </Fieldset>
</div> </Accordion>
<hr></hr>
<div class="mt-2 flex justify-end pt-2"> <div class="mt-2 flex justify-end pt-2">
<Button <Button
class="self-end" class="self-end"

View File

@@ -11,7 +11,6 @@ import { Match, Switch } from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineAvatar } from "./avatar"; import { MachineAvatar } from "./avatar";
import { DynForm } from "@/src/Form/form"; import { DynForm } from "@/src/Form/form";
import { Typography } from "@/src/components/Typography";
import Fieldset from "@/src/Form/fieldset"; import Fieldset from "@/src/Form/fieldset";
import Accordion from "@/src/components/accordion"; import Accordion from "@/src/components/accordion";

View File

@@ -1,9 +1,4 @@
import { callApi, SuccessData } from "@/src/api"; import { callApi, SuccessData } from "@/src/api";
import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import { import {
createForm, createForm,
FieldValues, FieldValues,
@@ -14,6 +9,12 @@ import {
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"; import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"; import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import Accordion from "@/src/components/accordion";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineAvatar } from "./avatar"; import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header"; import { Header } from "@/src/layout/header";
@@ -26,6 +27,7 @@ import { DiskStep, DiskValues } from "./install/disk-step";
import { SummaryStep } from "./install/summary-step"; import { SummaryStep } from "./install/summary-step";
import cx from "classnames"; import cx from "classnames";
import { VarsStep, VarsValues } from "./install/vars-step"; import { VarsStep, VarsValues } from "./install/vars-step";
import Fieldset from "@/src/Form/fieldset";
type MachineFormInterface = MachineData & { type MachineFormInterface = MachineData & {
sshKey?: File; sshKey?: File;
@@ -242,7 +244,7 @@ const InstallMachine = (props: InstallMachineProps) => {
<div class="flex flex-col items-center gap-3 fg-def-1"> <div class="flex flex-col items-center gap-3 fg-def-1">
<Typography <Typography
classList={{ classList={{
[cx("bg-inv-4 fg-inv-1")]: idx == step(), [cx("bg-inv-4 fg-inv-1")]: idx === step(),
[cx("bg-def-4 fg-def-1")]: idx < step(), [cx("bg-def-4 fg-def-1")]: idx < step(),
}} }}
color="inherit" color="inherit"
@@ -517,83 +519,71 @@ const MachineForm = (props: MachineDetailsProps) => {
return ( return (
<> <>
<div class="flex flex-col gap-6 p-4"> <div class="flex flex-col gap-6">
<span class="mb-2 flex w-full justify-center"> <div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
<MachineAvatar name={machineName()} /> <div class="flex items-center gap-3">
</span> <div class="w-fit" data-tip="Machine must be online">
<Form onSubmit={handleSubmit} class="flex flex-col gap-6"> {/* <Button
<Field name="machine.name"> class="w-full"
{(field, props) => ( size="s"
<TextInput // disabled={!online()}
inputProps={props} onClick={() => {
label="Name" setInstallModalOpen(true);
value={field.value ?? ""} }}
error={field.error} endIcon={<Icon icon="Flash" />}
class="col-span-2" >
required Install
/> </Button> */}
)} </div>
</Field> {/* <Typography hierarchy="label" size="default">
<Field name="machine.tags" type="string[]"> Installs the system for the first time. Used to bootstrap the
{(field, props) => ( remote device.
<> </Typography> */}
<FieldLayout </div>
label={<InputLabel>Tags</InputLabel>} <div class="flex items-center gap-3">
field={ <div class="button-group flex">
<span class="col-span-10"> <Button
<For each={field.value}> variant="light"
{(tag) => ( class="w-full"
<span class="mx-2 w-fit rounded-full px-3 py-1 bg-inv-4 fg-inv-1"> size="s"
{tag} onClick={() => {
</span> setInstallModalOpen(true);
)} }}
</For> endIcon={<Icon size={14} icon="Flash" />}
</span> >
} Install
/> </Button>
</> <Button
)} variant="light"
</Field> class="w-full"
<Field name="machine.description"> size="s"
{(field, props) => ( onClick={() => handleUpdate()}
<TextInput endIcon={<Icon size={12} icon="Update" />}
inputProps={props} >
label="Description" Update
value={field.value ?? ""} </Button>
error={field.error} </div>
class="col-span-2" <div class=" w-fit" data-tip="Machine must be online"></div>
required {/* <Typography hierarchy="label" size="default">
/> Update the system if changes should be synced after the
)} installation process.
</Field> </Typography> */}
<Field name="hw_config"> </div>
{(field, props) => ( </div>
<FieldLayout <div class="p-4">
label={<InputLabel>Hardware Configuration</InputLabel>} <span class="mb-2 flex w-full justify-center">
field={<span>{field.value || "None"}</span>} <MachineAvatar name={machineName()} />
/> </span>
)} <Form
</Field> onSubmit={handleSubmit}
<Field name="disk_schema.schema_name"> class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
{(field, props) => ( >
<> <Fieldset legend="General">
<FieldLayout <Field name="machine.name">
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</>
)}
</Field>
<div class=" col-span-full" tabindex="0">
<input type="checkbox" />
<div class=" px-0 text-xl ">Connection Settings</div>
<div class="">
<Field name="machine.deploy.targetHost">
{(field, props) => ( {(field, props) => (
<TextInput <TextInput
inputProps={props} inputProps={props}
label="Target Host" label="Name"
value={field.value ?? ""} value={field.value ?? ""}
error={field.error} error={field.error}
class="col-span-2" class="col-span-2"
@@ -601,73 +591,106 @@ const MachineForm = (props: MachineDetailsProps) => {
/> />
)} )}
</Field> </Field>
</div> <Field name="machine.description">
</div> {(field, props) => (
<TextInput
inputProps={props}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
/>
)}
</Field>
<Field name="machine.tags" type="string[]">
{(field, props) => (
<div class="flex items-center gap-4">
<Typography hierarchy="label" size="default" weight="bold">
Tags{" "}
</Typography>
<For each={field.value}>
{(tag) => (
<span class="mx-2 w-fit rounded-full px-3 py-0.5 bg-inv-4 fg-inv-1">
<Typography
hierarchy="label"
size="s"
inverted={true}
>
{tag}
</Typography>
</span>
)}
</For>
</div>
)}
</Field>
</Fieldset>
{ <Fieldset legend="Hardware">
<div class=" col-span-full justify-end"> <Field name="hw_config">
<Button {(field, props) => (
type="submit" <FieldLayout
disabled={formStore.submitting || !formStore.dirty} label={<InputLabel>Hardware Configuration</InputLabel>}
> field={<span>{field.value || "None"}</span>}
Save />
</Button> )}
</div> </Field>
} <hr />
</Form> <Field name="disk_schema.schema_name">
</div> {(field, props) => (
<>
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</>
)}
</Field>
</Fieldset>
<div class=""> <Accordion title="Connection Settings">
<div class=""></div> <Fieldset>
<Field name="machine.deploy.targetHost">
{(field, props) => (
<TextInput
inputProps={props}
label="Target Host"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
</Fieldset>
</Accordion>
<span class="text-xl text-primary-800">Actions</span> {
<div class="my-4 flex flex-col gap-6"> <footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
<span class="max-w-md"> <Button
Installs the system for the first time. Used to bootstrap the remote type="submit"
device. disabled={formStore.submitting || !formStore.dirty}
</span> >
<div class=" w-fit" data-tip="Machine must be online"> Update edits
<Button </Button>
class="w-full" </footer>
// disabled={!online()} }
onClick={() => { </Form>
setInstallModalOpen(true);
}}
endIcon={<Icon icon="Flash" />}
>
Install
</Button>
</div>
<Modal
title={`Install machine`}
open={installModalOpen()}
handleClose={() => setInstallModalOpen(false)}
class="min-w-[600px]"
>
<InstallMachine
name={machineName()}
targetHost={getValue(formStore, "machine.deploy.targetHost")}
machine={props.initialData}
/>
</Modal>
<span class="max-w-md">
Update the system if changes should be synced after the installation
process.
</span>
<div class=" w-fit" data-tip="Machine must be online">
<Button
class="w-full"
// disabled={!online()}
onClick={() => handleUpdateButton()}
endIcon={<Icon icon="Update" />}
>
Update
</Button>
</div>
</div> </div>
</div> </div>
<Modal
title={`Install machine`}
open={installModalOpen()}
handleClose={() => setInstallModalOpen(false)}
class="min-w-[600px]"
>
<InstallMachine
name={machineName()}
targetHost={getValue(formStore, "machine.deploy.targetHost")}
machine={props.initialData}
/>
</Modal>
</> </>
); );
}; };

View File

@@ -9,7 +9,7 @@ import {
import { activeURI } from "@/src/App"; import { activeURI } from "@/src/App";
import { callApi, OperationResponse } from "@/src/api"; import { callApi, OperationResponse } from "@/src/api";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem"; import { MachineListItem } from "@/src/components/machine-list-item";
import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { Button } from "@/src/components/button"; import { Button } from "@/src/components/button";
@@ -112,16 +112,6 @@ export const MachineListView: Component = () => {
/> />
<div> <div>
<div class="my-1 flex w-full gap-2 p-2"> <div class="my-1 flex w-full gap-2 p-2">
<div class="flex w-full justify-end px-4 py-1">
<div class="flex">
<Button
// onClick={() => navigate("create")}
size="s"
variant="light"
startIcon={<Icon icon="Filter" />}
></Button>
</div>
</div>
<For each={filter().tags.sort()}> <For each={filter().tags.sort()}>
{(tag) => ( {(tag) => (
<button <button
@@ -142,22 +132,39 @@ export const MachineListView: Component = () => {
)} )}
</For> </For>
</div> </div>
{/* </Show> */}
<Switch> <Switch>
<Match when={inventoryQuery.isLoading}> <Match when={inventoryQuery.isLoading}>
{/* Loading skeleton */} {/* Loading skeleton */}
<div> <div class="grid grid-cols-4"></div>
<div class=" m-2 shadow-lg"> <div class="machine-item-loader">
<figure class="pl-2"> <div class="machine-item-loader__thumb-wrapper">
<div class=" size-12"></div> <div class="machine-item-loader__thumb">
</figure> <div class="machine-item-loader__loader" />
<div class="">
<h2 class="">
<div class=" h-12 w-80"></div>
</h2>
<div class=" h-8 w-72"></div>
</div> </div>
</div> </div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div> </div>
</Match> </Match>
<Match <Match
@@ -178,10 +185,10 @@ export const MachineListView: Component = () => {
</Match> </Match>
<Match when={!inventoryQuery.isLoading}> <Match when={!inventoryQuery.isLoading}>
<div <div
class="my-4 flex flex-wrap gap-6 px-3 py-2" class="my-4 grid gap-6 p-6"
classList={{ classList={{
"flex-col": view() === "list", "grid-cols-1": view() === "list",
"": view() === "grid", "grid-cols-4": view() === "grid",
}} }}
> >
<For each={inventoryMachines()}> <For each={inventoryMachines()}>

View File

@@ -121,7 +121,7 @@ export const ModuleList = () => {
return ( return (
<> <>
<Header <Header
title="Modules" title="App Store"
toolbar={ toolbar={
<> <>
<Button <Button

View File

@@ -240,17 +240,17 @@ export default plugin.withOptions(
950: toRGB("#162324"), 950: toRGB("#162324"),
}, },
secondary: { secondary: {
50: toRGB("#f7f9f9"), 50: toRGB("#F7F9FA"),
100: toRGB("#e7f2f4"), 100: toRGB("#E7F2F4"),
200: toRGB("#d7e8ea"), 200: toRGB("#D8E8EB"),
300: toRGB("#afc6ca"), 300: toRGB("#AFC6CA"),
400: toRGB("#8fb2b6"), 400: toRGB("#90B2B7"),
500: toRGB("#7b9a9e"), 500: toRGB("#7B9B9F"),
600: toRGB("#4f747a"), 600: toRGB("#4F747A"),
700: toRGB("#415e63"), 700: toRGB("#415E63"),
800: toRGB("#445f64"), 800: toRGB("#446065"),
900: toRGB("#2b4347"), 900: toRGB("#2C4347"),
950: toRGB("#0d1415"), 950: toRGB("#0D1416"),
}, },
info: { info: {
50: toRGB("#eff9ff"), 50: toRGB("#eff9ff"),

View File

@@ -30,6 +30,9 @@ export default defineConfig({
"@": path.resolve(__dirname, "./"), // Adjust the path as needed "@": path.resolve(__dirname, "./"), // Adjust the path as needed
}, },
}, },
optimizeDeps: {
include: ["debug", "extend"],
},
plugins: [ plugins: [
/* /*
Uncomment the following line to enable solid-devtools. Uncomment the following line to enable solid-devtools.