Merge pull request 'UI: Improvements on install workflow.' (#2682) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2025-01-03 18:56:06 +00:00
17 changed files with 627 additions and 190 deletions

View File

@@ -233,16 +233,18 @@ def construct_value(
# Enums # Enums
if origin is Enum: if origin is Enum:
if field_value not in origin.__members__: try:
msg = f"Expected one of {', '.join(origin.__members__)}, got {field_value}" return t(field_value) # type: ignore
raise ClanError(msg, location=f"{loc}") except ValueError:
return origin.__members__[field_value] # type: ignore msg = f"Expected one of {', '.join(str(origin))}, got {field_value}"
raise ClanError(msg, location=f"{loc}") from ValueError
if isinstance(t, type) and issubclass(t, Enum): if isinstance(t, type) and issubclass(t, Enum):
if field_value not in t.__members__: try:
return t(field_value) # type: ignore
except ValueError:
msg = f"Expected one of {', '.join(t.__members__)}, got {field_value}" msg = f"Expected one of {', '.join(t.__members__)}, got {field_value}"
raise ClanError(msg, location=f"{loc}") raise ClanError(msg, location=f"{loc}") from ValueError
return t.__members__[field_value] # type: ignore
if origin is Annotated: if origin is Annotated:
(base_type,) = get_args(t) (base_type,) = get_args(t)

View File

@@ -18,6 +18,8 @@ from typing import (
is_typeddict, is_typeddict,
) )
from clan_cli.api.serde import dataclass_to_dict
class JSchemaTypeError(Exception): class JSchemaTypeError(Exception):
pass pass
@@ -257,7 +259,8 @@ def type_to_dict(
if type(t) is EnumType: if type(t) is EnumType:
return { return {
"type": "string", "type": "string",
"enum": list(t.__members__), # Construct every enum value and use the same method as the serde module for converting it into the same literal string
"enum": [dataclass_to_dict(t(value)) for value in t], # type: ignore
} }
if t is Any: if t is Any:
msg = f"{scope} - Usage of the Any type is not supported for API functions. In: {scope}" msg = f"{scope} - Usage of the Any type is not supported for API functions. In: {scope}"

View File

@@ -394,7 +394,8 @@ def run(
if options.check and process.returncode != 0: if options.check and process.returncode != 0:
err = ClanCmdError(cmd_out) err = ClanCmdError(cmd_out)
err.msg = "Command has been cancelled" err.msg = str(stderr_buf)
err.description = "Command has been cancelled"
raise err raise err
return cmd_out return cmd_out

View File

@@ -158,7 +158,6 @@ def find_deleted_paths(
for key, p_value in persisted.items(): for key, p_value in persisted.items():
current_path = f"{parent_key}.{key}" if parent_key else key current_path = f"{parent_key}.{key}" if parent_key else key
# Check if this key exists in update # Check if this key exists in update
# breakpoint()
if key not in update: if key not in update:
# Key doesn't exist at all -> entire branch deleted # Key doesn't exist at all -> entire branch deleted
deleted_paths.add(current_path) deleted_paths.add(current_path)

View File

@@ -266,3 +266,31 @@ def test_literal_field() -> None:
with pytest.raises(ClanError): with pytest.raises(ClanError):
# Not a valid value # Not a valid value
from_dict(Person, {"name": "open"}) from_dict(Person, {"name": "open"})
def test_enum_roundtrip() -> None:
from enum import Enum
class MyEnum(Enum):
FOO = "abc"
BAR = 2
@dataclass
class Person:
name: MyEnum
# Both are equivalent
data = {"name": "abc"} # JSON Representation
expected = Person(name=MyEnum.FOO) # Data representation
assert from_dict(Person, data) == expected
assert dataclass_to_dict(expected) == data
# Same test for integer values
data2 = {"name": 2} # JSON Representation
expected2 = Person(name=MyEnum.BAR) # Data representation
assert from_dict(Person, data2) == expected2
assert dataclass_to_dict(expected2) == data2

View File

@@ -27,7 +27,7 @@ export interface Option {
interface SelectInputpProps { interface SelectInputpProps {
value: string[] | string; value: string[] | string;
selectProps: JSX.InputHTMLAttributes<HTMLSelectElement>; selectProps?: JSX.InputHTMLAttributes<HTMLSelectElement>;
options: Option[]; options: Option[];
label: JSX.Element; label: JSX.Element;
labelProps?: InputLabelProps; labelProps?: InputLabelProps;

View File

@@ -1,5 +1,10 @@
import { splitProps, type JSX } from "solid-js"; import { splitProps, type JSX } from "solid-js";
import { InputBase, InputError, InputLabel } from "@/src/components/inputBase"; import {
InputBase,
InputError,
InputLabel,
InputVariant,
} from "@/src/components/inputBase";
import { Typography } from "@/src/components/Typography"; import { Typography } from "@/src/components/Typography";
import { FieldLayout } from "./layout"; import { FieldLayout } from "./layout";
@@ -12,10 +17,11 @@ interface TextInputProps {
value: string; value: string;
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>; inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
placeholder?: string; placeholder?: string;
variant?: InputVariant;
// Passed to label // Passed to label
label: JSX.Element; label: JSX.Element;
help?: string; help?: string;
// Passed to layouad // Passed to layout
class?: string; class?: string;
} }
@@ -35,6 +41,7 @@ export function TextInput(props: TextInputProps) {
} }
field={ field={
<InputBase <InputBase
variant={props.variant}
error={!!props.error} error={!!props.error}
required={props.required} required={props.required}
disabled={props.disabled} disabled={props.disabled}

View File

@@ -15,14 +15,11 @@ export const FieldLayout = (props: LayoutProps) => {
]); ]);
return ( return (
<div <div
class={cx("grid grid-cols-12 items-center", intern.class)} class={cx("grid grid-cols-10 items-center", intern.class)}
classList={{
"mb-[14.5px]": !props.error,
}}
{...divProps} {...divProps}
> >
<label class="col-span-2">{props.label}</label> <label class="col-span-5">{props.label}</label>
<div class="col-span-10">{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

@@ -2,25 +2,25 @@
@import "./typography-color.css"; @import "./typography-color.css";
.fnt-weight-normal { .fnt-weight-normal {
font-weight: normal; font-weight: 300;
} }
.fnt-weight-medium { .fnt-weight-medium {
font-weight: medium; font-weight: 500;
} }
.fnt-weight-bold { .fnt-weight-bold {
font-weight: bold; font-weight: 700;
} }
.fnt-weight-normal.fnt-clr--inverted { .fnt-weight-normal.fnt-clr--inverted {
font-weight: light; font-weight: 300;
} }
.fnt-weight-medium.fnt-clr--inverted { .fnt-weight-medium.fnt-clr--inverted {
font-weight: normal; font-weight: 400;
} }
.fnt-weight-bold.fnt-clr--inverted { .fnt-weight-bold.fnt-clr--inverted {
font-weight: 600; font-weight: 700;
} }

View File

@@ -85,6 +85,9 @@ interface _TypographyProps<H extends Hierarchy> {
tag?: Tag; tag?: Tag;
class?: string; class?: string;
classList?: Record<string, boolean>; classList?: Record<string, boolean>;
// Disable using the color prop
// A font color is provided via class / classList or inherited
useExternColor?: boolean;
} }
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => { export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
@@ -92,7 +95,7 @@ export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
<Dynamic <Dynamic
component={props.tag || "span"} component={props.tag || "span"}
class={cx( class={cx(
colorMap[props.color || "primary"], !props.useExternColor && colorMap[props.color || "primary"],
props.inverted && "fnt-clr--inverted", props.inverted && "fnt-clr--inverted",
sizeHierarchyMap[props.hierarchy][props.size] as string, sizeHierarchyMap[props.hierarchy][props.size] as string,
weightMap[props.weight || "normal"], weightMap[props.weight || "normal"],

View File

@@ -5,7 +5,9 @@ import { Typography } from "../Typography";
type Variants = "dark" | "light" | "ghost"; type Variants = "dark" | "light" | "ghost";
type Size = "default" | "s"; type Size = "default" | "s";
const variantColors: Record<Variants, string> = { const variantColors: (
disabled: boolean | undefined,
) => Record<Variants, string> = (disabled) => ({
dark: cx( dark: cx(
"border border-solid", "border border-solid",
"border-secondary-950 bg-primary-900 text-white", "border-secondary-950 bg-primary-900 text-white",
@@ -13,9 +15,10 @@ const variantColors: Record<Variants, string> = {
// Hover state // Hover state
// Focus state // Focus state
// Active state // Active state
"hover:border-secondary-900 hover:bg-secondary-700", !disabled && "hover:border-secondary-900 hover:bg-secondary-700",
"focus:border-secondary-900", !disabled && "focus:border-secondary-900",
"active:border-secondary-900 active:shadow-inner-primary-active", !disabled &&
"active:border-secondary-900 active:shadow-inner-primary-active",
// Disabled // Disabled
"disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300", "disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300",
), ),
@@ -26,9 +29,10 @@ const variantColors: Record<Variants, string> = {
// Hover state // Hover state
// Focus state // Focus state
// Active state // Active state
"hover:bg-secondary-200 hover:text-secondary-900", !disabled && "hover:bg-secondary-200 hover:text-secondary-900",
"focus:bg-secondary-200 focus:text-secondary-900", !disabled && "focus:bg-secondary-200 focus:text-secondary-900",
"active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active", !disabled &&
"active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active",
// Disabled // Disabled
"disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700", "disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700",
), ),
@@ -37,13 +41,14 @@ const variantColors: Record<Variants, string> = {
// Hover state // Hover state
// Focus state // Focus state
// Active state // Active state
"hover:bg-secondary-200 hover:text-secondary-900", !disabled && "hover:bg-secondary-200 hover:text-secondary-900",
"focus:bg-secondary-200 focus:text-secondary-900", !disabled && "focus:bg-secondary-200 focus:text-secondary-900",
"active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active", !disabled &&
"active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active",
// Disabled // Disabled
"disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700", "disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700",
), ),
}; });
const sizePaddings: Record<Size, string> = { const sizePaddings: Record<Size, string> = {
default: cx("rounded-[0.1875rem] px-4 py-2"), default: cx("rounded-[0.1875rem] px-4 py-2"),
@@ -82,7 +87,7 @@ export const Button = (props: ButtonProps) => {
"p-4", "p-4",
sizePaddings[local.size || "default"], sizePaddings[local.size || "default"],
// Colors // Colors
variantColors[local.variant || "dark"], variantColors(props.disabled)[local.variant || "dark"],
//Font //Font
"leading-none font-semibold", "leading-none font-semibold",
sizeFont[local.size || "default"], sizeFont[local.size || "default"],

View File

@@ -3,10 +3,11 @@ import ArrowBottom from "@/icons/arrow-bottom.svg";
import ArrowLeft from "@/icons/arrow-left.svg"; import ArrowLeft from "@/icons/arrow-left.svg";
import ArrowRight from "@/icons/arrow-right.svg"; import ArrowRight from "@/icons/arrow-right.svg";
import ArrowTop from "@/icons/arrow-top.svg"; import ArrowTop from "@/icons/arrow-top.svg";
import Attention from "@/icons/attention.svg";
import CaretDown from "@/icons/caret-down.svg"; import CaretDown from "@/icons/caret-down.svg";
import CaretUp from "@/icons/caret-up.svg";
import CaretLeft from "@/icons/caret-left.svg"; import CaretLeft from "@/icons/caret-left.svg";
import CaretRight from "@/icons/caret-right.svg"; import CaretRight from "@/icons/caret-right.svg";
import CaretUp from "@/icons/caret-up.svg";
import Checkmark from "@/icons/checkmark.svg"; import Checkmark from "@/icons/checkmark.svg";
import ClanIcon from "@/icons/clan-icon.svg"; import ClanIcon from "@/icons/clan-icon.svg";
import ClanLogo from "@/icons/clan-logo.svg"; import ClanLogo from "@/icons/clan-logo.svg";
@@ -16,31 +17,33 @@ import Edit from "@/icons/edit.svg";
import Expand from "@/icons/expand.svg"; import Expand from "@/icons/expand.svg";
import EyeClose from "@/icons/eye-close.svg"; import EyeClose from "@/icons/eye-close.svg";
import EyeOpen from "@/icons/eye-open.svg"; import EyeOpen from "@/icons/eye-open.svg";
import Flash from "@/icons/flash.svg";
import Filter from "@/icons/filter.svg"; import Filter from "@/icons/filter.svg";
import Flash from "@/icons/flash.svg";
import Folder from "@/icons/folder.svg"; import Folder from "@/icons/folder.svg";
import More from "@/icons/more.svg";
import Report from "@/icons/report.svg";
import Search from "@/icons/search.svg";
import Grid from "@/icons/grid.svg"; import Grid from "@/icons/grid.svg";
import Info from "@/icons/info.svg"; import Info from "@/icons/info.svg";
import List from "@/icons/list.svg"; import List from "@/icons/list.svg";
import Load from "@/icons/load.svg"; import Load from "@/icons/load.svg";
import More from "@/icons/more.svg";
import Paperclip from "@/icons/paperclip.svg"; import Paperclip from "@/icons/paperclip.svg";
import Plus from "@/icons/plus.svg"; import Plus from "@/icons/plus.svg";
import Reload from "@/icons/reload.svg"; import Reload from "@/icons/reload.svg";
import Report from "@/icons/report.svg";
import Search from "@/icons/search.svg";
import Settings from "@/icons/settings.svg"; import Settings from "@/icons/settings.svg";
import Trash from "@/icons/trash.svg"; import Trash from "@/icons/trash.svg";
import Update from "@/icons/update.svg"; import Update from "@/icons/update.svg";
import Warning from "@/icons/warning.svg";
const icons = { const icons = {
ArrowBottom, ArrowBottom,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
ArrowTop, ArrowTop,
Attention,
CaretDown, CaretDown,
CaretRight,
CaretLeft, CaretLeft,
CaretRight,
CaretUp, CaretUp,
Checkmark, Checkmark,
ClanIcon, ClanIcon,
@@ -52,21 +55,22 @@ const icons = {
EyeClose, EyeClose,
EyeOpen, EyeOpen,
Filter, Filter,
Folder,
More,
Report,
Search,
Flash, Flash,
Folder,
Grid, Grid,
Info, Info,
List, List,
Load, Load,
More,
Paperclip, Paperclip,
Plus, Plus,
Reload, Reload,
Report,
Search,
Settings, Settings,
Trash, Trash,
Update, Update,
Warning,
}; };
export type IconVariant = keyof typeof icons; export type IconVariant = keyof typeof icons;

View File

@@ -3,9 +3,9 @@ import { JSX, Ref, Show, splitProps } from "solid-js";
import Icon, { IconVariant } from "../icon"; import Icon, { IconVariant } from "../icon";
import { Typography, TypographyProps } from "../Typography"; import { Typography, TypographyProps } from "../Typography";
type Variants = "outlined" | "ghost"; export type InputVariant = "outlined" | "ghost";
interface InputBaseProps { interface InputBaseProps {
variant?: Variants; variant?: InputVariant;
value?: string; value?: string;
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>; inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
required?: boolean; required?: boolean;
@@ -22,7 +22,7 @@ interface InputBaseProps {
divRef?: Ref<HTMLDivElement>; divRef?: Ref<HTMLDivElement>;
} }
const variantBorder: Record<Variants, string> = { const variantBorder: Record<InputVariant, string> = {
outlined: "border border-inv-3", outlined: "border border-inv-3",
ghost: "", ghost: "",
}; };
@@ -115,40 +115,55 @@ export const InputLabel = (props: InputLabelProps) => {
class={cx("flex items-center gap-1", labelProps.class)} class={cx("flex items-center gap-1", labelProps.class)}
{...forwardProps} {...forwardProps}
> >
<Typography <span class="flex flex-col justify-center">
hierarchy="label" <span>
size="default" <Typography
weight="bold" hierarchy="label"
class="inline-flex gap-1 align-middle !fg-def-1" size="default"
classList={{ weight="bold"
[cx("!fg-semantic-1")]: !!props.error, class="inline-flex gap-1 align-middle !fg-def-1"
}} classList={{
aria-invalid={props.error} [cx("!fg-semantic-1")]: !!props.error,
>
{props.children}
{props.required && (
<span class="inline-flex px-1 align-bottom leading-[0.5] fg-def-3">
{"*"}
</span>
)}
{props.help && (
<span
class="tooltip tooltip-bottom inline px-2"
data-tip={props.help}
style={{
"--tooltip-color": "#EFFFFF",
"--tooltip-text-color": "#0D1416",
"--tooltip-tail": "0.8125rem",
}} }}
aria-invalid={props.error}
> >
<Icon class="inline fg-def-3" icon={"Info"} width={"0.8125rem"} /> {props.children}
</span> </Typography>
)} {props.required && (
</Typography> <Typography
class="inline-flex px-1 align-text-top leading-[0.5] fg-def-4"
useExternColor={true}
hierarchy="label"
weight="bold"
size="xs"
>
{""}
</Typography>
)}
{props.help && (
<span
class="tooltip tooltip-bottom inline px-2"
data-tip={props.help}
style={{
"--tooltip-color": "#EFFFFF",
"--tooltip-text-color": "#0D1416",
"--tooltip-tail": "0.8125rem",
}}
>
<Icon class="inline fg-def-3" icon={"Info"} width={"0.8125rem"} />
</span>
)}
</span>
<Typography
hierarchy="body"
size="xs"
weight="normal"
color="secondary"
>
{props.description}
</Typography>
</span>
{props.labelAction} {props.labelAction}
<Typography hierarchy="body" size="xs" weight="normal" color="secondary">
{props.description}
</Typography>
</label> </label>
); );
}; };

View File

@@ -9,6 +9,7 @@ interface ModalProps {
handleClose: () => void; handleClose: () => void;
title: string; title: string;
children: JSX.Element; children: JSX.Element;
class?: string;
} }
export const Modal = (props: ModalProps) => { export const Modal = (props: ModalProps) => {
const [dragging, setDragging] = createSignal(false); const [dragging, setDragging] = createSignal(false);
@@ -46,7 +47,10 @@ export const Modal = (props: ModalProps) => {
/> />
<Dialog.Content <Dialog.Content
class="absolute left-1/3 top-1/3 z-50 min-w-[320px] rounded-md border border-def-4 focus-visible:outline-none" class={cx(
"overflow-hidden absolute left-1/3 top-1/3 z-50 min-w-[560px] rounded-md border border-def-4 focus-visible:outline-none",
props.class,
)}
classList={{ classList={{
"!cursor-grabbing": dragging(), "!cursor-grabbing": dragging(),
[cx("scale-105 transition-transform")]: dragging(), [cx("scale-105 transition-transform")]: dragging(),
@@ -63,7 +67,7 @@ export const Modal = (props: ModalProps) => {
> >
<Dialog.Label <Dialog.Label
as="div" as="div"
class="flex w-full justify-center rounded-t-md border-b-2 px-4 py-2 align-middle bg-def-3 border-def-5" class="flex w-full justify-center border-b-2 px-4 py-2 align-middle bg-def-3 border-def-5"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
<div <div
@@ -79,7 +83,9 @@ export const Modal = (props: ModalProps) => {
<hr class="h-px w-full border-none bg-secondary-300" /> <hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" /> <hr class="h-px w-full border-none bg-secondary-300" />
</div> </div>
<span class="mx-2"> {props.title}</span> <span class="mx-2 select-none whitespace-nowrap">
{props.title}
</span>
<div <div
class="flex w-full cursor-move flex-col gap-px py-1 " class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{ classList={{

View File

@@ -2,7 +2,7 @@ import { callApi, SuccessData, SuccessQuery } from "@/src/api";
import { activeURI } from "@/src/App"; import { activeURI } from "@/src/App";
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, { IconVariant } from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput"; import { TextInput } from "@/src/Form/fields/TextInput";
import { selectSshKeys } from "@/src/hooks"; import { selectSshKeys } from "@/src/hooks";
import { import {
@@ -13,12 +13,17 @@ import {
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, JSX, Match, Show, Switch } from "solid-js";
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";
import { InputLabel } from "@/src/components/inputBase"; import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout"; import { FieldLayout } from "@/src/Form/fields/layout";
import { Modal } from "@/src/components/modal";
import { Typography } from "@/src/components/Typography";
import cx from "classnames";
import { SelectInput } from "@/src/Form/fields/Select";
import { HWStep } from "./install/hardware-step";
type MachineFormInterface = MachineData & { type MachineFormInterface = MachineData & {
sshKey?: File; sshKey?: File;
@@ -33,6 +38,65 @@ interface InstallForm extends FieldValues {
disk?: string; disk?: string;
} }
interface GroupProps {
children: JSX.Element;
}
export const Group = (props: GroupProps) => (
<div class="flex flex-col gap-8 rounded-md border px-4 py-5 bg-def-2 border-def-2">
{props.children}
</div>
);
type AdmonitionVariant = "attention" | "danger";
interface SectionHeaderProps {
variant: AdmonitionVariant;
headline: JSX.Element;
}
const variantColorsMap: Record<AdmonitionVariant, string> = {
attention: cx("bg-[#9BD8F2] fg-def-1"),
danger: cx("bg-semantic-2 fg-semantic-2"),
};
const variantIconColorsMap: Record<AdmonitionVariant, string> = {
attention: cx("fg-def-1"),
danger: cx("fg-semantic-3"),
};
const variantIconMap: Record<AdmonitionVariant, IconVariant> = {
attention: "Attention",
danger: "Warning",
};
export const SectionHeader = (props: SectionHeaderProps) => (
<div
class={cx(
"flex items-center gap-3 rounded-md px-3 py-2",
variantColorsMap[props.variant],
)}
>
{
<Icon
icon={variantIconMap[props.variant]}
class={cx("size-5", variantIconColorsMap[props.variant])}
/>
}
{props.headline}
</div>
);
const steps = {
"1": "Hardware detection",
"2": "Disk schema",
"3": "Installation",
};
interface SectionProps {
children: JSX.Element;
}
const Section = (props: SectionProps) => (
<div class="flex flex-col gap-3">{props.children}</div>
);
interface InstallMachineProps { interface InstallMachineProps {
name?: string; name?: string;
targetHost?: string | null; targetHost?: string | null;
@@ -90,7 +154,6 @@ const InstallMachine = (props: InstallMachineProps) => {
}; };
const handleDiskConfirm = async (e: Event) => { const handleDiskConfirm = async (e: Event) => {
e.preventDefault();
const curr_uri = activeURI(); const curr_uri = activeURI();
const disk = getValue(formStore, "disk"); const disk = getValue(formStore, "disk");
const disk_id = props.disks.find((d) => d.name === disk)?.id_link; const disk_id = props.disks.find((d) => d.name === disk)?.id_link;
@@ -98,9 +161,9 @@ const InstallMachine = (props: InstallMachineProps) => {
return; return;
} }
}; };
const [stepsDone, setStepsDone] = createSignal<StepIdx[]>([]);
const generateReport = async (e: Event) => { const generateReport = async (e: Event) => {
e.preventDefault();
const curr_uri = activeURI(); const curr_uri = activeURI();
if (!curr_uri || !props.name) { if (!curr_uri || !props.name) {
return; return;
@@ -113,7 +176,7 @@ const InstallMachine = (props: InstallMachineProps) => {
machine: props.name, machine: props.name,
keyfile: props.sshKey?.name, keyfile: props.sshKey?.name,
target_host: props.targetHost, target_host: props.targetHost,
backend: "NIXOS_FACTER", backend: "nixos-facter",
}, },
}); });
toast.dismiss(loading_toast); toast.dismiss(loading_toast);
@@ -126,97 +189,205 @@ const InstallMachine = (props: InstallMachineProps) => {
toast.success("Report generated successfully"); toast.success("Report generated successfully");
} }
}; };
type StepIdx = keyof typeof steps;
const [step, setStep] = createSignal<StepIdx>("1");
const handleNext = () => {
console.log("Next");
setStep((c) => `${+c + 1}` as StepIdx);
};
const handlePrev = () => {
console.log("Next");
setStep((c) => `${+c - 1}` as StepIdx);
};
const Footer = () => (
<div class="flex justify-between">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={handlePrev}
disabled={step() === "1"}
>
Previous
</Button>
<Button
endIcon={<Icon icon="ArrowRight" />}
type="submit"
// IMPORTANT: The step itself will try to submit and call the next step
// onClick={(e: Event) => handleNext()}
>
Next
</Button>
</div>
);
return ( return (
<> <div>
<Form onSubmit={handleInstall}> <div class="select-none px-6 py-2">
<h3 class="text-lg font-bold"> <Typography hierarchy="label" size="default">
<span class="font-normal">Install: </span> Install:{" "}
</Typography>
<Typography hierarchy="label" size="default" weight="bold">
{props.name} {props.name}
</h3> </Typography>
<p class="py-4"> </div>
Install the system for the first time. This will erase the disk and {/* Stepper container */}
bootstrap a new device. <div class="flex items-center justify-evenly gap-2 border py-3 bg-def-3 border-def-2">
</p> {/* A Step with a circle a number inside. Label is below */}
<For each={Object.entries(steps)}>
{([idx, label]) => (
<div class="flex flex-col items-center gap-3 fg-def-1">
<Typography
classList={{
[cx("bg-inv-4 fg-inv-1")]: idx == step(),
[cx("bg-def-4 fg-def-1")]: idx < step(),
}}
useExternColor={true}
hierarchy="label"
size="default"
weight="bold"
class="flex size-6 items-center justify-center rounded-full text-center align-middle bg-def-1"
>
<Show
when={idx >= step()}
fallback={<Icon icon="Checkmark" class="size-5" />}
>
{idx}
</Show>
</Typography>
<Typography
useExternColor={true}
hierarchy="label"
size="xs"
weight="medium"
class="text-center align-top fg-def-3"
classList={{
[cx("!fg-def-1")]: idx == step(),
}}
>
{label}
</Typography>
</div>
)}
</For>
</div>
<div class="flex flex-col"> <div class="flex flex-col gap-6 p-6">
<div class="text-lg font-semibold">Hardware detection</div> <Switch fallback={"Undefined content. This Step seems to not exist."}>
<div class="flex justify-between py-4"> <Match when={step() === "1"}>
<div class=""> <HWStep
<Button initial={{
variant="light" target: props.targetHost || "",
size="s" }}
class="w-full" // TODO: Context wrapper that redirects
onclick={generateReport} // @ts-expect-error: This cannot be undefined in this context.
endIcon={<Icon icon="Report" />} machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeURI()}
handleNext={() => handleNext()}
footer={<Footer />}
/>
</Match>
<Match when={step() === "2"}>
<span class="flex flex-col gap-4">
<Typography hierarchy="body" size="default" weight="bold">
Single Disk
</Typography>
<Typography
hierarchy="body"
size="xs"
weight="medium"
class="underline"
> >
Run hardware Report Change schema
</Button> </Typography>
</div> </span>
</div> <Group>
<div class="text-lg font-semibold">Disk schema</div> <SelectInput required label="Main Disk" options={[]} value={[]} />
<div class="flex justify-between py-4"> </Group>
<div class=""> <Footer />
<Button </Match>
variant="light" <Match when={step() === "3"}>
size="s" <Section>
class="w-full" <Typography
onclick={generateReport} hierarchy="label"
endIcon={<Icon icon="Flash" />} size="xs"
weight="medium"
class="uppercase"
> >
Select disk Schema Hardware Report
</Button> </Typography>
</div> <Group>
</div> <FieldLayout
</div> label={<InputLabel>Target</InputLabel>}
field={
<Field name="disk">{(field, fieldProps) => "disk"}</Field> <Typography hierarchy="body" size="xs" weight="bold">
<div role="alert" class="alert my-4"> 192.157.124.81
<span class="material-icons">info</span> </Typography>
<div> }
<div class="font-semibold">Summary:</div> ></FieldLayout>
<div class="mb-2"> </Group>
Install to <b>{props.targetHost}</b> using{" "} </Section>
<b>{props.sshKey?.name || "default ssh key"}</b> for <Section>
authentication. <Typography
</div> hierarchy="label"
This may take ~15 minutes depending on the initial closure and the size="xs"
environmental setup. weight="medium"
</div> class="uppercase"
</div>
<div class="modal-action">
<Show
when={confirmDisk()}
fallback={
<Button
class="btn btn-primary btn-wide"
onClick={handleDiskConfirm}
disabled={!hasDisk()}
endIcon={<Icon icon="Flash" />}
> >
Install Disk Configuration
</Button> </Typography>
} <Group>
> <FieldLayout
<Button label={<InputLabel>Disk Layout</InputLabel>}
class="w-full" field={
type="submit" <Typography hierarchy="body" size="xs" weight="bold">
endIcon={<Icon icon="Flash" />} Single Disk
> </Typography>
Install }
</Button> ></FieldLayout>
</Show> <hr class="h-px w-full border-none bg-acc-3"></hr>
<form method="dialog"> <FieldLayout
<Button label={<InputLabel>Main Disk</InputLabel>}
variant="light" field={
onClick={() => setConfirmDisk(false)} <Typography hierarchy="body" size="xs" weight="bold">
class="btn" Samsung evo 850 efkjhasd
> </Typography>
Close }
</Button> ></FieldLayout>
</form> </Group>
</div> </Section>
</Form> <SectionHeader
</> variant="danger"
headline={
<span>
<Typography
hierarchy="body"
size="s"
weight="bold"
useExternColor
>
Setup your device.
</Typography>
<Typography
hierarchy="body"
size="s"
weight="medium"
useExternColor
>
This will erase the disk and bootstrap fresh.
</Typography>
</span>
}
/>
<Footer></Footer>
<Button startIcon={<Icon icon="Flash" />}>Install</Button>
</Match>
</Switch>
</div>
</div>
); );
}; };
@@ -235,6 +406,8 @@ const MachineForm = (props: MachineDetailsProps) => {
const machineName = () => const machineName = () =>
getValue(formStore, "machine.name") || props.initialData.machine.name; getValue(formStore, "machine.name") || props.initialData.machine.name;
const [installModalOpen, setInstallModalOpen] = createSignal(false);
const handleSubmit = async (values: MachineFormInterface) => { const handleSubmit = async (values: MachineFormInterface) => {
console.log("submitting", values); console.log("submitting", values);
@@ -309,7 +482,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}> <Form onSubmit={handleSubmit} class="flex flex-col gap-6">
<Field name="machine.name"> <Field name="machine.name">
{(field, props) => ( {(field, props) => (
<TextInput <TextInput
@@ -452,10 +625,7 @@ const MachineForm = (props: MachineDetailsProps) => {
class="w-full" class="w-full"
// disabled={!online()} // disabled={!online()}
onClick={() => { onClick={() => {
const modal = document.getElementById( setInstallModalOpen(true);
"install_modal",
) as HTMLDialogElement | null;
modal?.showModal();
}} }}
endIcon={<Icon icon="Flash" />} endIcon={<Icon icon="Flash" />}
> >
@@ -463,16 +633,19 @@ const MachineForm = (props: MachineDetailsProps) => {
</Button> </Button>
</div> </div>
<dialog id="install_modal" class="modal backdrop:bg-transparent"> <Modal
<div class="modal-box w-11/12 max-w-5xl"> title={`Install machine`}
<InstallMachine open={installModalOpen()}
name={machineName()} handleClose={() => setInstallModalOpen(false)}
sshKey={sshKey()} class="min-w-[600px]"
targetHost={getValue(formStore, "machine.deploy.targetHost")} >
disks={[]} <InstallMachine
/> name={machineName()}
</div> sshKey={sshKey()}
</dialog> targetHost={getValue(formStore, "machine.deploy.targetHost")}
disks={[]}
/>
</Modal>
<span class="max-w-md text-neutral"> <span class="max-w-md text-neutral">
Update the system if changes should be synced after the installation Update the system if changes should be synced after the installation

View File

@@ -0,0 +1,179 @@
import { callApi } from "@/src/api";
import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { InputError, InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout";
import {
createForm,
SubmitHandler,
FieldValues,
validate,
required,
getValue,
submit,
setValue,
} from "@modular-forms/solid";
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
import toast from "solid-toast";
import { Group } from "../details";
import { TextInput } from "@/src/Form/fields";
import { createQuery } from "@tanstack/solid-query";
interface Hardware extends FieldValues {
report: boolean;
target: string;
}
export interface StepProps {
machine_id: string;
dir: string;
handleNext: () => void;
footer: JSX.Element;
initial?: Partial<Hardware>;
}
export const HWStep = (props: StepProps) => {
const [formStore, { Form, Field }] = createForm<Hardware>({
initialValues: props.initial || {},
});
const handleSubmit: SubmitHandler<Hardware> = async (values, event) => {
console.log("Submit Hardware", { values });
const valid = await validate(formStore);
console.log("Valid", valid);
if (!valid) return;
props.handleNext();
};
const [isGenerating, setIsGenerating] = createSignal(false);
const hwReportQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "hw_report"],
queryFn: async () => {
const result = await callApi("show_machine_hardware_config", {
clan_dir: props.dir,
machine_name: props.machine_id,
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
// Workaround to set the form state
createEffect(() => {
const report = hwReportQuery.data;
if (report === "nixos-facter" || report === "nixos-generate-config") {
setValue(formStore, "report", true);
}
});
const generateReport = async (e: Event) => {
const curr_uri = activeURI();
if (!curr_uri) return;
const loading_toast = toast.loading("Generating hardware report...");
await validate(formStore, "target");
const target = getValue(formStore, "target");
if (!target) {
toast.error("Target ip must be provided");
return;
}
setIsGenerating(true);
const r = await callApi("generate_machine_hardware_info", {
opts: {
flake: { loc: curr_uri },
machine: props.machine_id,
target_host: target,
backend: "nixos-facter",
},
});
setIsGenerating(false);
toast.dismiss(loading_toast);
// TODO: refresh the machine details
if (r.status === "error") {
toast.error(`Failed to generate report. ${r.errors[0].message}`);
}
if (r.status === "success") {
toast.success("Report generated successfully");
}
hwReportQuery.refetch();
submit(formStore);
};
return (
<Form onSubmit={handleSubmit} class="flex flex-col gap-6">
<Group>
<Field name="target" validate={required("Target must be provided")}>
{(field, fieldProps) => (
<TextInput
error={field.error}
variant="ghost"
label="Target ip"
value={field.value || ""}
inputProps={fieldProps}
required
/>
)}
</Field>
</Group>
<Group>
<Field
name="report"
type="boolean"
validate={required("Report must be generated")}
>
{(field, fieldProps) => (
<FieldLayout
error={field.error && <InputError error={field.error} />}
label={
<InputLabel
required
help="Detect hardware specific drivers from target ip"
>
Hardware report
</InputLabel>
}
field={
<Switch>
<Match when={hwReportQuery.isLoading}>
<div>Loading...</div>
</Match>
<Match when={hwReportQuery.error}>
<div>Error...</div>
</Match>
<Match when={hwReportQuery.data}>
{(data) => (
<>
<Switch>
<Match when={data() === "none"}>
<Button
disabled={isGenerating()}
startIcon={<Icon icon="Report" />}
class="w-full"
onClick={generateReport}
>
Run hardware report
</Button>
</Match>
<Match when={data() === "nixos-facter"}>
<div>Detected</div>
</Match>
<Match when={data() === "nixos-generate-config"}>
<div>Nixos report Detected</div>
</Match>
</Switch>
</>
)}
</Match>
</Switch>
}
/>
)}
</Field>
</Group>
{props.footer}
</Form>
);
};

View File

@@ -128,6 +128,21 @@ export default plugin.withOptions(
".bg-acc-4": { ".bg-acc-4": {
backgroundColor: theme("colors.secondary.300"), backgroundColor: theme("colors.secondary.300"),
}, },
// bg inverse accent
".bg-semantic-1": {
backgroundColor: theme("colors.error.50"),
},
".bg-semantic-2": {
backgroundColor: theme("colors.error.100"),
},
".bg-semantic-3": {
backgroundColor: theme("colors.error.200"),
},
".bg-semantic-4": {
backgroundColor: theme("colors.error.300"),
},
// bg inverse accent // bg inverse accent
".bg-inv-acc-1": { ".bg-inv-acc-1": {
backgroundColor: theme("colors.secondary.500"), backgroundColor: theme("colors.secondary.500"),