diff --git a/pkgs/clan-cli/clan_cli/api/serde.py b/pkgs/clan-cli/clan_cli/api/serde.py index 49b2f256b..e15be8e2d 100644 --- a/pkgs/clan-cli/clan_cli/api/serde.py +++ b/pkgs/clan-cli/clan_cli/api/serde.py @@ -233,16 +233,18 @@ def construct_value( # Enums if origin is Enum: - if field_value not in origin.__members__: - msg = f"Expected one of {', '.join(origin.__members__)}, got {field_value}" - raise ClanError(msg, location=f"{loc}") - return origin.__members__[field_value] # type: ignore + try: + return t(field_value) # type: ignore + except ValueError: + 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 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}" - raise ClanError(msg, location=f"{loc}") - return t.__members__[field_value] # type: ignore + raise ClanError(msg, location=f"{loc}") from ValueError if origin is Annotated: (base_type,) = get_args(t) diff --git a/pkgs/clan-cli/clan_cli/api/util.py b/pkgs/clan-cli/clan_cli/api/util.py index d0812af06..404f5e39f 100644 --- a/pkgs/clan-cli/clan_cli/api/util.py +++ b/pkgs/clan-cli/clan_cli/api/util.py @@ -18,6 +18,8 @@ from typing import ( is_typeddict, ) +from clan_cli.api.serde import dataclass_to_dict + class JSchemaTypeError(Exception): pass @@ -257,7 +259,8 @@ def type_to_dict( if type(t) is EnumType: return { "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: msg = f"{scope} - Usage of the Any type is not supported for API functions. In: {scope}" diff --git a/pkgs/clan-cli/clan_cli/cmd.py b/pkgs/clan-cli/clan_cli/cmd.py index 53d0634c4..8ffb9881d 100644 --- a/pkgs/clan-cli/clan_cli/cmd.py +++ b/pkgs/clan-cli/clan_cli/cmd.py @@ -394,7 +394,8 @@ def run( if options.check and process.returncode != 0: err = ClanCmdError(cmd_out) - err.msg = "Command has been cancelled" + err.msg = str(stderr_buf) + err.description = "Command has been cancelled" raise err return cmd_out diff --git a/pkgs/clan-cli/clan_cli/inventory/__init__.py b/pkgs/clan-cli/clan_cli/inventory/__init__.py index 87f63db57..ea8d65278 100644 --- a/pkgs/clan-cli/clan_cli/inventory/__init__.py +++ b/pkgs/clan-cli/clan_cli/inventory/__init__.py @@ -158,7 +158,6 @@ def find_deleted_paths( for key, p_value in persisted.items(): current_path = f"{parent_key}.{key}" if parent_key else key # Check if this key exists in update - # breakpoint() if key not in update: # Key doesn't exist at all -> entire branch deleted deleted_paths.add(current_path) diff --git a/pkgs/clan-cli/tests/test_deserializers.py b/pkgs/clan-cli/tests/test_deserializers.py index ecbbfa06a..bd6e577ff 100644 --- a/pkgs/clan-cli/tests/test_deserializers.py +++ b/pkgs/clan-cli/tests/test_deserializers.py @@ -266,3 +266,31 @@ def test_literal_field() -> None: with pytest.raises(ClanError): # Not a valid value 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 diff --git a/pkgs/webview-ui/app/src/Form/fields/Select.tsx b/pkgs/webview-ui/app/src/Form/fields/Select.tsx index c8f67c6d7..26990bbcf 100644 --- a/pkgs/webview-ui/app/src/Form/fields/Select.tsx +++ b/pkgs/webview-ui/app/src/Form/fields/Select.tsx @@ -27,7 +27,7 @@ export interface Option { interface SelectInputpProps { value: string[] | string; - selectProps: JSX.InputHTMLAttributes; + selectProps?: JSX.InputHTMLAttributes; options: Option[]; label: JSX.Element; labelProps?: InputLabelProps; diff --git a/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx b/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx index c37214b30..24f3ca357 100644 --- a/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx +++ b/pkgs/webview-ui/app/src/Form/fields/TextInput.tsx @@ -1,5 +1,10 @@ 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 { FieldLayout } from "./layout"; @@ -12,10 +17,11 @@ interface TextInputProps { value: string; inputProps?: JSX.InputHTMLAttributes; placeholder?: string; + variant?: InputVariant; // Passed to label label: JSX.Element; help?: string; - // Passed to layouad + // Passed to layout class?: string; } @@ -35,6 +41,7 @@ export function TextInput(props: TextInputProps) { } field={ { ]); return (
- -
{props.field}
+ +
{props.field}
{props.error && {props.error}}
); diff --git a/pkgs/webview-ui/app/src/components/Typography/css/typography.css b/pkgs/webview-ui/app/src/components/Typography/css/typography.css index e0d1b4075..735507f74 100644 --- a/pkgs/webview-ui/app/src/components/Typography/css/typography.css +++ b/pkgs/webview-ui/app/src/components/Typography/css/typography.css @@ -2,25 +2,25 @@ @import "./typography-color.css"; .fnt-weight-normal { - font-weight: normal; + font-weight: 300; } .fnt-weight-medium { - font-weight: medium; + font-weight: 500; } .fnt-weight-bold { - font-weight: bold; + font-weight: 700; } .fnt-weight-normal.fnt-clr--inverted { - font-weight: light; + font-weight: 300; } .fnt-weight-medium.fnt-clr--inverted { - font-weight: normal; + font-weight: 400; } .fnt-weight-bold.fnt-clr--inverted { - font-weight: 600; + font-weight: 700; } diff --git a/pkgs/webview-ui/app/src/components/Typography/index.tsx b/pkgs/webview-ui/app/src/components/Typography/index.tsx index dc5410d58..29957aa35 100644 --- a/pkgs/webview-ui/app/src/components/Typography/index.tsx +++ b/pkgs/webview-ui/app/src/components/Typography/index.tsx @@ -85,6 +85,9 @@ interface _TypographyProps { tag?: Tag; class?: string; classList?: Record; + // Disable using the color prop + // A font color is provided via class / classList or inherited + useExternColor?: boolean; } export const Typography = (props: _TypographyProps) => { @@ -92,7 +95,7 @@ export const Typography = (props: _TypographyProps) => { = { +const variantColors: ( + disabled: boolean | undefined, +) => Record = (disabled) => ({ dark: cx( "border border-solid", "border-secondary-950 bg-primary-900 text-white", @@ -13,9 +15,10 @@ const variantColors: Record = { // Hover state // Focus state // Active state - "hover:border-secondary-900 hover:bg-secondary-700", - "focus:border-secondary-900", - "active:border-secondary-900 active:shadow-inner-primary-active", + !disabled && "hover:border-secondary-900 hover:bg-secondary-700", + !disabled && "focus:border-secondary-900", + !disabled && + "active:border-secondary-900 active:shadow-inner-primary-active", // Disabled "disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300", ), @@ -26,9 +29,10 @@ const variantColors: Record = { // Hover state // Focus state // Active state - "hover:bg-secondary-200 hover:text-secondary-900", - "focus:bg-secondary-200 focus:text-secondary-900", - "active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active", + !disabled && "hover:bg-secondary-200 hover:text-secondary-900", + !disabled && "focus:bg-secondary-200 focus:text-secondary-900", + !disabled && + "active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active", // Disabled "disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700", ), @@ -37,13 +41,14 @@ const variantColors: Record = { // Hover state // Focus state // Active state - "hover:bg-secondary-200 hover:text-secondary-900", - "focus:bg-secondary-200 focus:text-secondary-900", - "active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active", + !disabled && "hover:bg-secondary-200 hover:text-secondary-900", + !disabled && "focus:bg-secondary-200 focus:text-secondary-900", + !disabled && + "active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active", // Disabled "disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700", ), -}; +}); const sizePaddings: Record = { default: cx("rounded-[0.1875rem] px-4 py-2"), @@ -82,7 +87,7 @@ export const Button = (props: ButtonProps) => { "p-4", sizePaddings[local.size || "default"], // Colors - variantColors[local.variant || "dark"], + variantColors(props.disabled)[local.variant || "dark"], //Font "leading-none font-semibold", sizeFont[local.size || "default"], diff --git a/pkgs/webview-ui/app/src/components/icon/index.tsx b/pkgs/webview-ui/app/src/components/icon/index.tsx index 2f04e956e..a64a9a23e 100644 --- a/pkgs/webview-ui/app/src/components/icon/index.tsx +++ b/pkgs/webview-ui/app/src/components/icon/index.tsx @@ -3,10 +3,11 @@ import ArrowBottom from "@/icons/arrow-bottom.svg"; import ArrowLeft from "@/icons/arrow-left.svg"; import ArrowRight from "@/icons/arrow-right.svg"; import ArrowTop from "@/icons/arrow-top.svg"; +import Attention from "@/icons/attention.svg"; import CaretDown from "@/icons/caret-down.svg"; -import CaretUp from "@/icons/caret-up.svg"; import CaretLeft from "@/icons/caret-left.svg"; import CaretRight from "@/icons/caret-right.svg"; +import CaretUp from "@/icons/caret-up.svg"; import Checkmark from "@/icons/checkmark.svg"; import ClanIcon from "@/icons/clan-icon.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 EyeClose from "@/icons/eye-close.svg"; import EyeOpen from "@/icons/eye-open.svg"; -import Flash from "@/icons/flash.svg"; import Filter from "@/icons/filter.svg"; +import Flash from "@/icons/flash.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 Info from "@/icons/info.svg"; import List from "@/icons/list.svg"; import Load from "@/icons/load.svg"; +import More from "@/icons/more.svg"; import Paperclip from "@/icons/paperclip.svg"; import Plus from "@/icons/plus.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 Trash from "@/icons/trash.svg"; import Update from "@/icons/update.svg"; +import Warning from "@/icons/warning.svg"; const icons = { ArrowBottom, ArrowLeft, ArrowRight, ArrowTop, + Attention, CaretDown, - CaretRight, CaretLeft, + CaretRight, CaretUp, Checkmark, ClanIcon, @@ -52,21 +55,22 @@ const icons = { EyeClose, EyeOpen, Filter, - Folder, - More, - Report, - Search, Flash, + Folder, Grid, Info, List, Load, + More, Paperclip, Plus, Reload, + Report, + Search, Settings, Trash, Update, + Warning, }; export type IconVariant = keyof typeof icons; diff --git a/pkgs/webview-ui/app/src/components/inputBase/index.tsx b/pkgs/webview-ui/app/src/components/inputBase/index.tsx index 39780146d..eda2298ff 100644 --- a/pkgs/webview-ui/app/src/components/inputBase/index.tsx +++ b/pkgs/webview-ui/app/src/components/inputBase/index.tsx @@ -3,9 +3,9 @@ import { JSX, Ref, Show, splitProps } from "solid-js"; import Icon, { IconVariant } from "../icon"; import { Typography, TypographyProps } from "../Typography"; -type Variants = "outlined" | "ghost"; +export type InputVariant = "outlined" | "ghost"; interface InputBaseProps { - variant?: Variants; + variant?: InputVariant; value?: string; inputProps?: JSX.InputHTMLAttributes; required?: boolean; @@ -22,7 +22,7 @@ interface InputBaseProps { divRef?: Ref; } -const variantBorder: Record = { +const variantBorder: Record = { outlined: "border border-inv-3", ghost: "", }; @@ -115,40 +115,55 @@ export const InputLabel = (props: InputLabelProps) => { class={cx("flex items-center gap-1", labelProps.class)} {...forwardProps} > - - {props.children} - {props.required && ( - - {"*"} - - )} - {props.help && ( - + + - - - )} - + {props.children} + + {props.required && ( + + {"∗"} + + )} + {props.help && ( + + + + )} + + + {props.description} + + {props.labelAction} - - {props.description} - ); }; diff --git a/pkgs/webview-ui/app/src/components/modal/index.tsx b/pkgs/webview-ui/app/src/components/modal/index.tsx index c6869fe67..70f554fb2 100644 --- a/pkgs/webview-ui/app/src/components/modal/index.tsx +++ b/pkgs/webview-ui/app/src/components/modal/index.tsx @@ -9,6 +9,7 @@ interface ModalProps { handleClose: () => void; title: string; children: JSX.Element; + class?: string; } export const Modal = (props: ModalProps) => { const [dragging, setDragging] = createSignal(false); @@ -46,7 +47,10 @@ export const Modal = (props: ModalProps) => { /> { >
{

- {props.title} + + {props.title} +
( +
+ {props.children} +
+); + +type AdmonitionVariant = "attention" | "danger"; +interface SectionHeaderProps { + variant: AdmonitionVariant; + headline: JSX.Element; +} +const variantColorsMap: Record = { + attention: cx("bg-[#9BD8F2] fg-def-1"), + danger: cx("bg-semantic-2 fg-semantic-2"), +}; + +const variantIconColorsMap: Record = { + attention: cx("fg-def-1"), + danger: cx("fg-semantic-3"), +}; + +const variantIconMap: Record = { + attention: "Attention", + danger: "Warning", +}; + +export const SectionHeader = (props: SectionHeaderProps) => ( +
+ { + + } + {props.headline} +
+); + +const steps = { + "1": "Hardware detection", + "2": "Disk schema", + "3": "Installation", +}; + +interface SectionProps { + children: JSX.Element; +} +const Section = (props: SectionProps) => ( +
{props.children}
+); + interface InstallMachineProps { name?: string; targetHost?: string | null; @@ -90,7 +154,6 @@ const InstallMachine = (props: InstallMachineProps) => { }; const handleDiskConfirm = async (e: Event) => { - e.preventDefault(); const curr_uri = activeURI(); const disk = getValue(formStore, "disk"); const disk_id = props.disks.find((d) => d.name === disk)?.id_link; @@ -98,9 +161,9 @@ const InstallMachine = (props: InstallMachineProps) => { return; } }; + const [stepsDone, setStepsDone] = createSignal([]); const generateReport = async (e: Event) => { - e.preventDefault(); const curr_uri = activeURI(); if (!curr_uri || !props.name) { return; @@ -113,7 +176,7 @@ const InstallMachine = (props: InstallMachineProps) => { machine: props.name, keyfile: props.sshKey?.name, target_host: props.targetHost, - backend: "NIXOS_FACTER", + backend: "nixos-facter", }, }); toast.dismiss(loading_toast); @@ -126,97 +189,205 @@ const InstallMachine = (props: InstallMachineProps) => { toast.success("Report generated successfully"); } }; + + type StepIdx = keyof typeof steps; + const [step, setStep] = createSignal("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 = () => ( +
+ + +
+ ); return ( - <> -
-

- Install: +
+
+ + Install:{" "} + + {props.name} -

-

- Install the system for the first time. This will erase the disk and - bootstrap a new device. -

+ +
+ {/* Stepper container */} +
+ {/* A Step with a circle a number inside. Label is below */} + + {([idx, label]) => ( +
+ + = step()} + fallback={} + > + {idx} + + + + {label} + +
+ )} +
+
-
-
Hardware detection
-
-
- -
-
-
Disk schema
-
-
- -
-
-
- - {(field, fieldProps) => "disk"} - - - - + Disk Configuration + + + Disk Layout} + field={ + + Single Disk + + } + > +
+ Main Disk} + field={ + + Samsung evo 850 efkjhasd + + } + > +
+ + + + Setup your device. + + + This will erase the disk and bootstrap fresh. + + + } + /> +
+ + + + + ); }; @@ -235,6 +406,8 @@ const MachineForm = (props: MachineDetailsProps) => { const machineName = () => getValue(formStore, "machine.name") || props.initialData.machine.name; + const [installModalOpen, setInstallModalOpen] = createSignal(false); + const handleSubmit = async (values: MachineFormInterface) => { console.log("submitting", values); @@ -309,7 +482,7 @@ const MachineForm = (props: MachineDetailsProps) => {
General -
+ {(field, props) => ( { class="w-full" // disabled={!online()} onClick={() => { - const modal = document.getElementById( - "install_modal", - ) as HTMLDialogElement | null; - modal?.showModal(); + setInstallModalOpen(true); }} endIcon={} > @@ -463,16 +633,19 @@ const MachineForm = (props: MachineDetailsProps) => {
- - - + setInstallModalOpen(false)} + class="min-w-[600px]" + > + + Update the system if changes should be synced after the installation diff --git a/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx b/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx new file mode 100644 index 000000000..8ce0ab499 --- /dev/null +++ b/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx @@ -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; +} +export const HWStep = (props: StepProps) => { + const [formStore, { Form, Field }] = createForm({ + initialValues: props.initial || {}, + }); + + const handleSubmit: SubmitHandler = 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 ( + + + + {(field, fieldProps) => ( + + )} + + + + + {(field, fieldProps) => ( + } + label={ + + Hardware report + + } + field={ + + +
Loading...
+
+ +
Error...
+
+ + {(data) => ( + <> + + + + + +
Detected
+
+ +
Nixos report Detected
+
+
+ + )} +
+
+ } + /> + )} +
+
+ {props.footer} + + ); +}; diff --git a/pkgs/webview-ui/app/tailwind/core-plugin.ts b/pkgs/webview-ui/app/tailwind/core-plugin.ts index 55a658766..b40862895 100644 --- a/pkgs/webview-ui/app/tailwind/core-plugin.ts +++ b/pkgs/webview-ui/app/tailwind/core-plugin.ts @@ -128,6 +128,21 @@ export default plugin.withOptions( ".bg-acc-4": { 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-inv-acc-1": { backgroundColor: theme("colors.secondary.500"),