Merge pull request 'API: Display only local block devices, remote ones should be retrieved from hw-report' (#2642) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-12-20 19:20:54 +00:00
16 changed files with 497 additions and 478 deletions

View File

@@ -1,7 +1,7 @@
site_name: Clan Documentation site_name: Clan Documentation
site_url: https://docs.clan.lol site_url: https://docs.clan.lol
repo_url: https://git.clan.lol/clan/clan-core/ repo_url: https://git.clan.lol/clan/clan-core/
repo_name: clan-core repo_name: "_>"
edit_uri: _edit/main/docs/docs/ edit_uri: _edit/main/docs/docs/
validation: validation:
@@ -148,11 +148,13 @@ theme:
features: features:
- navigation.instant - navigation.instant
- navigation.tabs - navigation.tabs
- navigation.tabs.sticky
- navigation.footer
- content.code.annotate - content.code.annotate
- content.code.copy - content.code.copy
- content.tabs.link - content.tabs.link
icon: icon:
repo: fontawesome/brands/git-alt repo: fontawesome/brands/git
custom_dir: overrides custom_dir: overrides
palette: palette:
@@ -181,7 +183,7 @@ extra:
social: social:
- icon: fontawesome/regular/comment - icon: fontawesome/regular/comment
link: https://matrix.to/#/#clan:clan.lol link: https://matrix.to/#/#clan:clan.lol
- icon: fontawesome/brands/gitlab - icon: fontawesome/brands/git
link: https://git.clan.lol/clan/clan-core link: https://git.clan.lol/clan/clan-core
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/clan-lol/clan-core link: https://github.com/clan-lol/clan-core

View File

@@ -1,10 +1,12 @@
# Authoring a clanModule # Authoring a clanModule
!!! Danger ":fontawesome-solid-road-barrier: Under Construction :fontawesome-solid-road-barrier:"
Currently under construction use with caution
:fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier:
This site will guide you through authoring your first module. Explaining which conventions must be followed, such that others will have an enjoyable experience and the module can be used with minimal effort. This site will guide you through authoring your first module. Explaining which conventions must be followed, such that others will have an enjoyable experience and the module can be used with minimal effort.
:fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier:
Under construction
:fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier:
!!! Tip !!! Tip
External ClanModules can be ad-hoc loaded via [`clan.inventory.modules`](../reference/nix-api/inventory.md#modules) External ClanModules can be ad-hoc loaded via [`clan.inventory.modules`](../reference/nix-api/inventory.md#modules)

View File

@@ -1,9 +1,11 @@
# Disk Templates # Disk Templates
!!! Danger "!!! Under construction !!!" !!! Danger ":fontawesome-solid-road-barrier: Under Construction :fontawesome-solid-road-barrier:"
Currently under construction use with caution Currently under construction use with caution
:fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier: :fontawesome-solid-road-barrier:
## Structure ## Structure

View File

@@ -118,37 +118,17 @@ def blk_from_dict(data: dict) -> BlkInfo:
) )
@dataclass
class BlockDeviceOptions:
hostname: str | None = None
keyfile: str | None = None
@API.register @API.register
def show_block_devices(options: BlockDeviceOptions) -> Blockdevices: def show_block_devices() -> Blockdevices:
""" """
Abstract api method to show block devices. Api method to show local block devices.
It must return a list of block devices. It must return a list of block devices.
""" """
keyfile = options.keyfile
remote = (
[
"ssh",
*(["-i", f"{keyfile}"] if keyfile else []),
# Disable strict host key checking
"-o StrictHostKeyChecking=accept-new",
# Disable known hosts file
"-o UserKnownHostsFile=/dev/null",
f"{options.hostname}",
]
if options.hostname
else []
)
cmd = nix_shell( cmd = nix_shell(
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if options.hostname else [])], ["nixpkgs#util-linux"],
[ [
*remote,
"lsblk", "lsblk",
"--json", "--json",
"--output", "--output",

View File

@@ -7,14 +7,22 @@ import {
createMemo, createMemo,
} from "solid-js"; } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import cx from "classnames";
import { Label } from "../base/label";
import { useFloating } from "../base"; import { useFloating } from "../base";
import { autoUpdate, flip, hide, shift, size } from "@floating-ui/dom"; import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom";
import { Button } from "@/src/components/button";
import {
InputBase,
InputError,
InputLabel,
InputLabelProps,
} from "@/src/components/inputBase";
import { FieldLayout } from "./layout";
import Icon from "@/src/components/icon";
export interface Option { export interface Option {
value: string; value: string;
label: string; label: string;
disabled?: boolean;
} }
interface SelectInputpProps { interface SelectInputpProps {
@@ -22,7 +30,7 @@ interface SelectInputpProps {
selectProps: JSX.InputHTMLAttributes<HTMLSelectElement>; selectProps: JSX.InputHTMLAttributes<HTMLSelectElement>;
options: Option[]; options: Option[];
label: JSX.Element; label: JSX.Element;
altLabel?: JSX.Element; labelProps?: InputLabelProps;
helperText?: JSX.Element; helperText?: JSX.Element;
error?: string; error?: string;
required?: boolean; required?: boolean;
@@ -36,6 +44,7 @@ interface SelectInputpProps {
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
multiple?: boolean; multiple?: boolean;
loading?: boolean;
} }
export function SelectInput(props: SelectInputpProps) { export function SelectInput(props: SelectInputpProps) {
@@ -61,6 +70,7 @@ export function SelectInput(props: SelectInputpProps) {
}); });
}, },
}), }),
offset({ mainAxis: 2 }),
shift(), shift(),
flip(), flip(),
hide({ hide({
@@ -114,126 +124,144 @@ export function SelectInput(props: SelectInputpProps) {
return ( return (
<> <>
<label <FieldLayout
class={cx("form-control w-full", props.class)} error={props.error && <InputError error={props.error} />}
aria-disabled={props.disabled} label={
> <InputLabel
<div class="label"> description={""}
<Label label={props.label} required={props.required} />
<span class="label-text-alt block">{props.altLabel}</span>
</div>
<button
type="button"
class="select select-bordered flex items-center gap-2"
ref={setReference}
formnovalidate
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
// TODO: Use native popover once Webkti supports it within <form>
// popovertarget={_id}
// 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">
<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="btn-ghost btn-xs"
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>
</div>
<select
class="hidden"
multiple
{...props.selectProps}
required={props.required} required={props.required}
{...props.labelProps}
> >
<For each={props.options}> {props.label}
{({ label, value }) => ( </InputLabel>
<option value={value} selected={getValues().includes(value)}> }
{label} field={
</option> <>
)} <InputBase
</For> error={!!props.error}
</select> disabled={props.disabled}
<Show when={props.adornment && props.adornment.position === "end"}> required={props.required}
{props.adornment?.content} class="!justify-start"
</Show> divRef={setReference}
</button> inputElem={
<Portal mount={document.body}> <button
<div // TODO: Keyboard acessibililty
id={_id} // Currently the popover only opens with onClick
popover // Options are not selectable with keyboard
ref={setFloating} tabIndex={-1}
style={{ onClick={() => {
margin: 0, const popover = document.getElementById(_id);
position: position.strategy, if (popover) {
top: `${position.y ?? 0}px`, popover.togglePopover(); // Show or hide the popover
left: `${position.x ?? 0}px`, }
}} }}
class="dropdown-content z-[1] rounded-b-box bg-base-100 shadow" type="button"
> class="flex w-full items-center gap-2"
<ul class="menu flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll"> formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// 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
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
}
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="btn-ghost btn-xs"
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>
}
/>
</>
}
/>
<Portal mount={document.body}>
<div
id={_id}
popover
ref={setFloating}
style={{
margin: 0,
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="z-[1] shadow"
>
<ul class="menu flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
<Show when={!props.loading} fallback={"Loading ...."}>
<For each={props.options}> <For each={props.options}>
{(opt) => ( {(opt) => (
<> <>
<li> <li>
<button <Button
variant="ghost"
class="!justify-start"
onClick={() => handleClickOption(opt)} onClick={() => handleClickOption(opt)}
disabled={opt.disabled}
classList={{ classList={{
active: getValues().includes(opt.value), active:
!opt.disabled && getValues().includes(opt.value),
}} }}
> >
{opt.label} {opt.label}
</button> </Button>
</li> </li>
</> </>
)} )}
</For> </For>
</ul> </Show>
</div> </ul>
</Portal>
<div class="label">
{props.helperText && (
<span class="label-text text-neutral">{props.helperText}</span>
)}
{props.error && (
<span class="label-text-alt font-bold text-error-700">
{props.error}
</span>
)}
</div> </div>
</label> </Portal>
</> </>
); );
} }

View File

@@ -1,62 +1,51 @@
import { createEffect, Show, type JSX } from "solid-js"; import { splitProps, type JSX } from "solid-js";
import cx from "classnames"; import { InputBase, InputError, InputLabel } from "@/src/components/inputBase";
import { Label } from "../base/label";
import { InputBase, InputLabel } from "@/src/components/inputBase";
import { Typography } from "@/src/components/Typography"; import { Typography } from "@/src/components/Typography";
import { FieldLayout } from "./layout";
interface TextInputProps { interface TextInputProps {
value: string; // Common
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
label: JSX.Element;
altLabel?: JSX.Element;
helperText?: JSX.Element;
error?: string; error?: string;
required?: boolean; required?: boolean;
type?: string;
inlineLabel?: JSX.Element;
class?: string;
adornment?: {
position: "start" | "end";
content: JSX.Element;
};
disabled?: boolean; disabled?: boolean;
// Passed to input
value: string;
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
placeholder?: string; placeholder?: string;
// Passed to label
label: JSX.Element;
help?: string;
// Passed to layouad
class?: string;
} }
export function TextInput(props: TextInputProps) { export function TextInput(props: TextInputProps) {
// createEffect(() => { const [layoutProps, rest] = splitProps(props, ["class"]);
// console.log("TextInput", props.error, props.value);
// });
return ( return (
<div <FieldLayout
class="grid grid-cols-12" label={
classList={{ <InputLabel
"mb-[14.5px]": !props.error, class="col-span-2"
}} required={props.required}
> error={!!props.error}
<InputLabel help={props.help}
class="col-span-2"
required={props.required}
error={!!props.error}
>
{props.label}
</InputLabel>
<InputBase
error={!!props.error}
class="col-span-10"
{...props.inputProps}
value={props.value}
/>
{props.error && (
<Typography
hierarchy="body"
size="xxs"
weight="medium"
class="col-span-full px-1 !fg-semantic-4"
> >
{props.error} {props.label}
</Typography> </InputLabel>
)} }
</div> field={
<InputBase
error={!!props.error}
required={props.required}
disabled={props.disabled}
placeholder={props.placeholder}
class="col-span-10"
{...props.inputProps}
value={props.value}
/>
}
error={props.error && <InputError error={props.error} />}
{...layoutProps}
/>
); );
} }

View File

@@ -0,0 +1,29 @@
import { JSX, splitProps } from "solid-js";
import cx from "classnames";
interface LayoutProps extends JSX.HTMLAttributes<HTMLDivElement> {
field?: JSX.Element;
label?: JSX.Element;
error?: JSX.Element;
}
export const FieldLayout = (props: LayoutProps) => {
const [intern, divProps] = splitProps(props, [
"field",
"label",
"error",
"class",
]);
return (
<div
class={cx("grid grid-cols-12 items-center", intern.class)}
classList={{
"mb-[14.5px]": !props.error,
}}
{...divProps}
>
<label class="col-span-2">{props.label}</label>
<div class="col-span-10">{props.field}</div>
{props.error && <span class="col-span-full">{props.error}</span>}
</div>
);
};

View File

@@ -248,7 +248,7 @@ export function StringField<T extends FieldValues, R extends ResponseData>(
{(options) => ( {(options) => (
<SelectInput <SelectInput
error={field.error} error={field.error}
altLabel={props.schema.title} // altLabel={props.schema.title}
label={props.path.join(".")} label={props.path.join(".")}
helperText={props.schema.description} helperText={props.schema.description}
value={field.value || []} value={field.value || []}
@@ -276,7 +276,7 @@ export function StringField<T extends FieldValues, R extends ResponseData>(
<TextInput <TextInput
inputProps={{ ...fieldProps, readonly }} inputProps={{ ...fieldProps, readonly }}
value={field.value as unknown as string} value={field.value as unknown as string}
type="password" // type="password"
error={field.error} error={field.error}
{...commonProps} {...commonProps}
// required // required
@@ -525,7 +525,7 @@ export function ArrayFields<T extends FieldValues, R extends ResponseData>(
<SelectInput <SelectInput
multiple multiple
error={field.error} error={field.error}
altLabel={props.schema.title} // altLabel={props.schema.title}
label={listFieldName} label={listFieldName}
helperText={props.schema.description} helperText={props.schema.description}
value={field.value || ""} value={field.value || ""}

View File

@@ -3,7 +3,7 @@ import { Dynamic } from "solid-js/web";
import cx from "classnames"; import cx from "classnames";
import "./css/typography.css"; import "./css/typography.css";
type Hierarchy = "body" | "title" | "headline" | "label"; export type Hierarchy = "body" | "title" | "headline" | "label";
type Color = "primary" | "secondary" | "tertiary"; type Color = "primary" | "secondary" | "tertiary";
type Weight = "normal" | "medium" | "bold"; type Weight = "normal" | "medium" | "bold";
type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4"; type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4";
@@ -75,7 +75,7 @@ const weightMap: Record<Weight, string> = {
bold: cx("fnt-weight-bold"), bold: cx("fnt-weight-bold"),
}; };
interface TypographyProps<H extends Hierarchy> { interface _TypographyProps<H extends Hierarchy> {
hierarchy: H; hierarchy: H;
size: AllowedSizes<H>; size: AllowedSizes<H>;
children: JSX.Element; children: JSX.Element;
@@ -86,7 +86,8 @@ interface TypographyProps<H extends Hierarchy> {
class?: string; class?: string;
classList?: Record<string, boolean>; classList?: Record<string, boolean>;
} }
export const Typography = <H extends Hierarchy>(props: TypographyProps<H>) => {
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
return ( return (
<Dynamic <Dynamic
component={props.tag || "span"} component={props.tag || "span"}
@@ -103,3 +104,5 @@ export const Typography = <H extends Hierarchy>(props: TypographyProps<H>) => {
</Dynamic> </Dynamic>
); );
}; };
export type TypographyProps = _TypographyProps<Hierarchy>;

View File

@@ -1,7 +1,7 @@
import cx from "classnames"; import cx from "classnames";
import { createEffect, JSX, splitProps } from "solid-js"; import { JSX, Ref, Show, splitProps } from "solid-js";
import Icon, { IconVariant } from "../icon"; import Icon, { IconVariant } from "../icon";
import { Typography } from "../Typography"; import { Typography, TypographyProps } from "../Typography";
type Variants = "outlined" | "ghost"; type Variants = "outlined" | "ghost";
interface InputBaseProps { interface InputBaseProps {
@@ -17,6 +17,9 @@ interface InputBaseProps {
readonly?: boolean; readonly?: boolean;
error?: boolean; error?: boolean;
icon?: IconVariant; icon?: IconVariant;
/** Overrides the input element */
inputElem?: JSX.Element;
divRef?: Ref<HTMLDivElement>;
} }
const variantBorder: Record<Variants, string> = { const variantBorder: Record<Variants, string> = {
@@ -27,11 +30,7 @@ const variantBorder: Record<Variants, string> = {
const fgStateClasses = cx("aria-disabled:fg-def-4 aria-readonly:fg-def-3"); const fgStateClasses = cx("aria-disabled:fg-def-4 aria-readonly:fg-def-3");
export const InputBase = (props: InputBaseProps) => { export const InputBase = (props: InputBaseProps) => {
const [, inputProps] = splitProps(props, ["class"]); const [internal, inputProps] = splitProps(props, ["class", "divRef"]);
createEffect(() => {
console.log("InputBase", props.value, props.variant);
});
return ( return (
<div <div
class={cx( class={cx(
@@ -66,6 +65,7 @@ export const InputBase = (props: InputBaseProps) => {
aria-readonly={props.readonly} aria-readonly={props.readonly}
tabIndex={0} tabIndex={0}
role="textbox" role="textbox"
ref={internal.divRef}
> >
{props.icon && ( {props.icon && (
<i <i
@@ -77,32 +77,39 @@ export const InputBase = (props: InputBaseProps) => {
<Icon icon={props.icon} font-size="inherit" color="inherit" /> <Icon icon={props.icon} font-size="inherit" color="inherit" />
</i> </i>
)} )}
<input <Show when={!props.inputElem} fallback={props.inputElem}>
tabIndex={-1} <input
class="w-full bg-transparent outline-none aria-readonly:cursor-no-drop" tabIndex={-1}
value={props.value} class="w-full bg-transparent outline-none"
type={props.type ? props.type : "text"} value={props.value}
readOnly={props.readonly} type={props.type ? props.type : "text"}
placeholder={`${props.placeholder || ""}`} readOnly={props.readonly}
required={props.required} placeholder={`${props.placeholder || ""}`}
disabled={props.disabled} required={props.required}
aria-invalid={props.error} disabled={props.disabled}
aria-disabled={props.disabled} aria-invalid={props.error}
aria-readonly={props.readonly} aria-disabled={props.disabled}
{...inputProps} aria-readonly={props.readonly}
/> {...inputProps}
/>
</Show>
</div> </div>
); );
}; };
interface InputLabelProps extends JSX.LabelHTMLAttributes<HTMLLabelElement> { export interface InputLabelProps
extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
description?: string; description?: string;
required?: boolean; required?: boolean;
error?: boolean; error?: boolean;
help?: string; help?: string;
labelAction?: JSX.Element;
} }
export const InputLabel = (props: InputLabelProps) => { export const InputLabel = (props: InputLabelProps) => {
const [labelProps, forwardProps] = splitProps(props, ["class"]); const [labelProps, forwardProps] = splitProps(props, [
"class",
"labelAction",
]);
return ( return (
<label <label
class={cx("flex items-center gap-1", labelProps.class)} class={cx("flex items-center gap-1", labelProps.class)}
@@ -112,7 +119,7 @@ export const InputLabel = (props: InputLabelProps) => {
hierarchy="label" hierarchy="label"
size="default" size="default"
weight="bold" weight="bold"
class="!fg-def-1" class="inline-flex gap-1 align-middle !fg-def-1"
classList={{ classList={{
[cx("!fg-semantic-1")]: !!props.error, [cx("!fg-semantic-1")]: !!props.error,
}} }}
@@ -138,9 +145,33 @@ export const InputLabel = (props: InputLabelProps) => {
</span> </span>
)} )}
</Typography> </Typography>
{props.labelAction}
<Typography hierarchy="body" size="xs" weight="normal" color="secondary"> <Typography hierarchy="body" size="xs" weight="normal" color="secondary">
{props.description} {props.description}
</Typography> </Typography>
</label> </label>
); );
}; };
interface InputErrorProps {
error: string;
typographyProps?: TypographyProps;
}
export const InputError = (props: InputErrorProps) => {
const [typoClasses, rest] = splitProps(
props.typographyProps || { class: "" },
["class"],
);
return (
<Typography
hierarchy="body"
// @ts-expect-error: Dependent type is to complex to check how it is coupled to the override for now
size="xxs"
weight="medium"
class={cx("col-span-full px-1 !fg-semantic-4", typoClasses)}
{...rest}
>
{props.error}
</Typography>
);
};

View File

@@ -1,65 +0,0 @@
import { callApi } from "@/src/api";
import { Component, For, Show } from "solid-js";
import { createQuery } from "@tanstack/solid-query";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
export const BlockDevicesView: Component = () => {
const {
data: devices,
refetch: loadDevices,
isFetching,
} = createQuery(() => ({
queryKey: ["block_devices"],
queryFn: async () => {
const result = await callApi("show_block_devices", { options: {} });
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
staleTime: 1000 * 60 * 5,
}));
return (
<div>
<div class="tooltip tooltip-bottom" data-tip="Refresh">
<Button
onClick={() => loadDevices()}
startIcon={<Icon icon="Update" />}
></Button>
</div>
<div class="flex max-w-screen-lg flex-col gap-4">
{isFetching ? (
<span class="loading loading-bars"></span>
) : (
<Show when={devices}>
{(devices) => (
<For each={devices().blockdevices}>
{(device) => (
<div class="stats shadow">
<div class="stat w-28 py-8">
<div class="stat-title">Name</div>
<div class="stat-value">
{" "}
<span class="material-icons">storage</span>{" "}
{device.name}
</div>
<div class="stat-desc"></div>
</div>
<div class="stat w-28">
<div class="stat-title">Size</div>
<div class="stat-value">{device.size}</div>
<div class="stat-desc"></div>
</div>
</div>
)}
</For>
)}
</Show>
)}
</div>
</div>
);
};

View File

@@ -159,12 +159,12 @@ export const CreateClan = () => {
<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">
<TextInput <TextInput
adornment={{ // adornment={{
content: ( // content: (
<span class="-mr-1 text-neutral-500">clan-core #</span> // <span class="-mr-1 text-neutral-500">clan-core #</span>
), // ),
position: "start", // position: "start",
}} // }}
inputProps={props} inputProps={props}
label="Template to use" label="Template to use"
value={field.value ?? ""} value={field.value ?? ""}

View File

@@ -217,12 +217,12 @@ const AdminModuleForm = (props: AdminModuleFormProps) => {
<TextInput <TextInput
inputProps={props} inputProps={props}
label={"Name"} label={"Name"}
adornment={{ // adornment={{
position: "start", // position: "start",
content: ( // content: (
<span class="material-icons text-gray-400">key</span> // <span class="material-icons text-gray-400">key</span>
), // ),
}} // }}
value={field.value ?? ""} value={field.value ?? ""}
error={field.error} error={field.error}
class="col-span-4" class="col-span-4"

View File

@@ -2,10 +2,12 @@ import { callApi } from "@/src/api";
import { Button } from "@/src/components/button"; import { Button } from "@/src/components/button";
import { FileInput } from "@/src/components/FileInput"; import { FileInput } from "@/src/components/FileInput";
import Icon from "@/src/components/icon"; import Icon from "@/src/components/icon";
import { SelectInput } from "@/src/components/SelectInput";
import { TextInput } from "@/src/Form/fields/TextInput";
import { Typography } from "@/src/components/Typography"; import { Typography } from "@/src/components/Typography";
import { Header } from "@/src/layout/header"; import { Header } from "@/src/layout/header";
import { SelectInput } from "@/src/Form/fields/Select";
import { TextInput } from "@/src/Form/fields/TextInput";
import { import {
createForm, createForm,
required, required,
@@ -16,6 +18,8 @@ import {
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Show } from "solid-js"; import { createEffect, createSignal, For, Show } from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase";
interface Wifi extends FieldValues { interface Wifi extends FieldValues {
ssid: string; ssid: string;
@@ -89,9 +93,7 @@ export const Flash = () => {
const deviceQuery = createQuery(() => ({ const deviceQuery = createQuery(() => ({
queryKey: ["block_devices"], queryKey: ["block_devices"],
queryFn: async () => { queryFn: async () => {
const result = await callApi("show_block_devices", { const result = await callApi("show_block_devices", {});
options: {},
});
if (result.status === "error") throw new Error("Failed to fetch data"); if (result.status === "error") throw new Error("Failed to fetch data");
return result.data; return result.data;
}, },
@@ -145,41 +147,43 @@ export const Flash = () => {
const handleSubmit = async (values: FlashFormValues) => { const handleSubmit = async (values: FlashFormValues) => {
console.log("Submit:", values); console.log("Submit:", values);
try { toast.error("Not fully implemented yet");
await callApi("flash_machine", { // Disabled for now. To prevent accidental flashing of local disks
machine: { // try {
name: values.machine.devicePath, // await callApi("flash_machine", {
flake: { // machine: {
loc: values.machine.flake, // name: values.machine.devicePath,
}, // flake: {
}, // loc: values.machine.flake,
mode: "format", // },
disks: [{ name: "main", device: values.disk }], // },
system_config: { // mode: "format",
language: values.language, // disks: [{ name: "main", device: values.disk }],
keymap: values.keymap, // system_config: {
ssh_keys_path: values.sshKeys.map((file) => file.name), // language: values.language,
}, // keymap: values.keymap,
dry_run: false, // ssh_keys_path: values.sshKeys.map((file) => file.name),
write_efi_boot_entries: false, // },
debug: false, // dry_run: false,
}); // write_efi_boot_entries: false,
} catch (error) { // debug: false,
toast.error(`Error could not flash disk: ${error}`); // });
console.error("Error submitting form:", error); // } catch (error) {
} // toast.error(`Error could not flash disk: ${error}`);
// console.error("Error submitting form:", error);
// }
}; };
return ( return (
<> <>
<Header title="Flash installer" /> <Header title="Flash installer" />
<div class="p-4"> <div class="p-4">
<Typography tag="p" hierarchy="body" size="default" color="secondary"> <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">
This will make bootstrapping a new machine easier by providing secure Will make bootstrapping new machines easier by providing secure remote
remote connection to any machine when plugged in. connection to any machine when plugged in.
</Typography> </Typography>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<div class="my-4"> <div class="my-4">
@@ -220,37 +224,30 @@ export const Flash = () => {
<Field name="disk" validate={[required("This field is required")]}> <Field name="disk" validate={[required("This field is required")]}>
{(field, props) => ( {(field, props) => (
<SelectInput <SelectInput
topRightLabel={ loading={deviceQuery.isFetching}
<Button
size="s"
variant="light"
onClick={(e) => {
e.preventDefault();
deviceQuery.refetch();
}}
startIcon={<Icon icon="Update" />}
></Button>
}
formStore={formStore}
selectProps={props} selectProps={props}
label="Flash Disk" label="Flash Disk"
value={String(field.value)} labelProps={{
labelAction: (
<Button
class="ml-auto"
variant="ghost"
size="s"
type="button"
startIcon={<Icon icon="Update" />}
onClick={() => deviceQuery.refetch()}
/>
),
}}
value={field.value || ""}
error={field.error} error={field.error}
required required
placeholder="Select a thing where the installer will be flashed to"
options={ options={
<> deviceQuery.data?.blockdevices.map((d) => ({
<option value="" disabled> value: d.path,
Select a disk where the installer will be flashed to label: `${d.path} -- ${d.size} bytes`,
</option> })) || []
<For each={deviceQuery.data?.blockdevices}>
{(device) => (
<option value={device.path}>
{device.path} -- {device.size} bytes
</option>
)}
</For>
</>
} }
/> />
)} )}
@@ -258,94 +255,100 @@ export const Flash = () => {
{/* WiFi Networks */} {/* WiFi Networks */}
<div class="my-4 py-2"> <div class="my-4 py-2">
<h3 class="mb-2 text-lg font-semibold">WiFi Networks</h3> <FieldLayout
<span class="mb-2 text-sm">Add preconfigured networks</span> label={<InputLabel class="mb-4">Networks</InputLabel>}
field={
<>
<Button
type="button"
size="s"
variant="light"
onClick={addWifiNetwork}
startIcon={<Icon icon="Plus" />}
>
WiFi Network
</Button>
</>
}
/>
<For each={wifiNetworks()}> <For each={wifiNetworks()}>
{(network, index) => ( {(network, index) => (
<div class="mb-2 grid grid-cols-7 gap-2"> <div class="flex w-full gap-2">
<Field <div class="mb-2 grid w-full grid-cols-6 gap-2 align-middle">
name={`wifi.${index()}.ssid`} <Field
validate={[required("SSID is required")]} name={`wifi.${index()}.ssid`}
> validate={[required("SSID is required")]}
{(field, props) => ( >
<TextInput {(field, props) => (
inputProps={props}
label="SSID"
value={field.value ?? ""}
error={field.error}
class="col-span-3"
required
/>
)}
</Field>
<Field
name={`wifi.${index()}.password`}
validate={[required("Password is required")]}
>
{(field, props) => (
<div class="relative col-span-3 w-full">
<TextInput <TextInput
inputProps={props} inputProps={props}
type={ label="SSID"
passwordVisibility()[index()] ? "text" : "password"
}
label="Password"
value={field.value ?? ""} value={field.value ?? ""}
error={field.error} error={field.error}
adornment={{ class="col-span-3"
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 required
/> />
</div> )}
)} </Field>
</Field> <Field
<div class="col-span-1 self-end"> name={`wifi.${index()}.password`}
<Button validate={[required("Password is required")]}
type="button" >
variant="light" {(field, props) => (
class="h-12" <div class="relative col-span-3 w-full">
onClick={() => removeWifiNetwork(index())} <TextInput
startIcon={<Icon icon="Trash" />} inputProps={{
></Button> ...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
/>
</div>
)}
</Field>
</div> </div>
<Button
type="button"
variant="light"
class="h-10"
size="s"
onClick={() => removeWifiNetwork(index())}
startIcon={<Icon icon="Trash" />}
></Button>
</div> </div>
)} )}
</For> </For>
<div class="">
<Button
type="button"
size="s"
variant="light"
onClick={addWifiNetwork}
startIcon={<Icon icon="Plus" />}
>
Add WiFi Network
</Button>
</div>
</div> </div>
<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 font-medium "> <div class="collapse-title px-0">
Advanced Settings <InputLabel class="mb-4">Advanced</InputLabel>
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<Field <Field
@@ -358,9 +361,6 @@ export const Flash = () => {
inputProps={props} inputProps={props}
label="Source (flake URL)" label="Source (flake URL)"
value={String(field.value)} value={String(field.value)}
inlineLabel={
<span class="material-icons">file_download</span>
}
error={field.error} error={field.error}
required required
/> />
@@ -377,21 +377,26 @@ export const Flash = () => {
inputProps={props} inputProps={props}
label="Image Name (attribute name)" label="Image Name (attribute name)"
value={String(field.value)} value={String(field.value)}
inlineLabel={<span class="material-icons">devices</span>}
error={field.error} error={field.error}
required required
/> />
</> </>
)} )}
</Field> </Field>
<div class="my-2 py-2"> <FieldLayout
<span class="text-sm text-neutral-600">Source URL: </span> label={
<span class="text-sm text-neutral-600"> <InputLabel help="Computed reference">Source Url</InputLabel>
{getValue(formStore, "machine.flake") + }
"#" + field={
getValue(formStore, "machine.devicePath")} <InputLabel>
</span> {getValue(formStore, "machine.flake") +
</div> "#" +
getValue(formStore, "machine.devicePath")}
</InputLabel>
}
/>
<hr class="mb-6"></hr>
<Field <Field
name="language" name="language"
validate={[required("This field is required")]} validate={[required("This field is required")]}
@@ -399,22 +404,22 @@ export const Flash = () => {
{(field, props) => ( {(field, props) => (
<> <>
<SelectInput <SelectInput
formStore={formStore}
selectProps={props} selectProps={props}
label="Language" label="Language"
value={String(field.value)} value={String(field.value)}
error={field.error} error={field.error}
required required
options={ loading={langQuery.isLoading}
<> options={[
<option value={"en_US.UTF-8"}>{"en_US.UTF-8"}</option> {
<For each={langQuery.data}> label: "en_US.UTF-8",
{(language) => ( value: "en_US.UTF-8",
<option value={language}>{language}</option> },
)} ...(langQuery.data?.map((lang) => ({
</For> label: lang,
</> value: lang,
} })) || []),
]}
/> />
</> </>
)} )}
@@ -427,22 +432,22 @@ export const Flash = () => {
{(field, props) => ( {(field, props) => (
<> <>
<SelectInput <SelectInput
formStore={formStore}
selectProps={props} selectProps={props}
label="Keymap" label="Keymap"
value={String(field.value)} value={String(field.value)}
error={field.error} error={field.error}
required required
options={ loading={keymapQuery.isLoading}
<> options={[
<option value={"en"}>{"en"}</option> {
<For each={keymapQuery.data}> label: "en",
{(keymap) => ( value: "en",
<option value={keymap}>{keymap}</option> },
)} ...(keymapQuery.data?.map((keymap) => ({
</For> label: keymap,
</> value: keymap,
} })) || []),
]}
/> />
</> </>
)} )}

View File

@@ -18,6 +18,7 @@ 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";
import { InputLabel } from "@/src/components/inputBase"; import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout";
type MachineFormInterface = MachineData & { type MachineFormInterface = MachineData & {
sshKey?: File; sshKey?: File;
@@ -308,7 +309,7 @@ const MachineForm = (props: MachineDetailsProps) => {
<div class="card-body"> <div class="card-body">
<span class="text-xl text-primary-800">General</span> <span class="text-xl text-primary-800">General</span>
<MachineAvatar name={machineName()} /> <MachineAvatar name={machineName()} />
<Form onSubmit={handleSubmit} class="grid grid-cols-12 gap-y-4"> <Form onSubmit={handleSubmit}>
<Field name="machine.name"> <Field name="machine.name">
{(field, props) => ( {(field, props) => (
<TextInput <TextInput
@@ -324,16 +325,20 @@ const MachineForm = (props: MachineDetailsProps) => {
<Field name="machine.tags" type="string[]"> <Field name="machine.tags" type="string[]">
{(field, props) => ( {(field, props) => (
<> <>
<InputLabel class="col-span-2">Tags</InputLabel> <FieldLayout
<span class="col-span-10"> label={<InputLabel>Tags</InputLabel>}
<For each={field.value}> field={
{(tag) => ( <span class="col-span-10">
<span class="mx-2 w-fit rounded-full px-3 py-1 bg-inv-4 fg-inv-1"> <For each={field.value}>
{tag} {(tag) => (
</span> <span class="mx-2 w-fit rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
)} {tag}
</For> </span>
</span> )}
</For>
</span>
}
/>
</> </>
)} )}
</Field> </Field>
@@ -351,19 +356,19 @@ const MachineForm = (props: MachineDetailsProps) => {
</Field> </Field>
<Field name="hw_config"> <Field name="hw_config">
{(field, props) => ( {(field, props) => (
<> <FieldLayout
<InputLabel class="col-span-2"> label={<InputLabel>Hardware Configuration</InputLabel>}
Hardware Configuration field={<span>{field.value || "None"}</span>}
</InputLabel> />
<span class="col-span-10">{field.value || "None"}</span>
</>
)} )}
</Field> </Field>
<Field name="disk_schema"> <Field name="disk_schema">
{(field, props) => ( {(field, props) => (
<> <>
<InputLabel class="col-span-2">Disk schema</InputLabel> <FieldLayout
<span class="col-span-10">{field.value || "None"}</span> label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</> </>
)} )}
</Field> </Field>
@@ -608,7 +613,8 @@ function WifiModule(props: MachineWifiProps) {
label="Password" label="Password"
value={field.value ?? ""} value={field.value ?? ""}
error={field.error} error={field.error}
type="password" // todo
// type="password"
required required
/> />
)} )}

View File

@@ -74,7 +74,14 @@ const mkBorderUtils = (
export default plugin.withOptions( export default plugin.withOptions(
(_options = {}) => (_options = {}) =>
({ addUtilities, theme }) => { ({ addUtilities, theme, addVariant, e }) => {
// @ts-expect-error: lib of tailwind has no types
addVariant("popover-open", ({ modifySelectors, separator }) => {
// @ts-expect-error: lib of tailwind has no types
modifySelectors(({ className }) => {
return `.${e(`popover-open${separator}${className}`)}:popover-open`;
});
});
addUtilities({ addUtilities({
// Background colors // Background colors
".bg-def-1": { ".bg-def-1": {