clan-app: Untangle Machine Details into separate components. Makes it non functional for now.

This commit is contained in:
Qubasa
2025-06-17 16:26:00 +02:00
parent 143dfc99dc
commit 0c432d5c25
26 changed files with 1730 additions and 862 deletions

View File

@@ -241,9 +241,9 @@ export function SelectInput(props: SelectInputpProps) {
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="z-[1000] shadow"
class="rounded-md border border-gray-200 bg-white shadow-lg"
>
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll p-1">
<Show when={!props.loading} fallback={"Loading ...."}>
<For each={props.options}>
{(opt) => (

View File

@@ -0,0 +1,287 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import {
RemoteForm,
RemoteData,
Machine,
RemoteDataSource,
} from "./RemoteForm";
import { createSignal } from "solid-js";
import { fn } from "@storybook/test";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
// Default values for the form
const defaultRemoteData: RemoteData = {
address: "",
user: "",
command_prefix: "sudo",
port: undefined,
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 0,
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
};
// Sample data for populated form
const sampleRemoteData: RemoteData = {
address: "example.com",
user: "admin",
command_prefix: "sudo",
port: 22,
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 1,
verbose_ssh: false,
ssh_options: {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
},
tor_socks: false,
};
// Sample machine data for testing
const sampleMachine: Machine = {
name: "test-machine",
flake: {
identifier: "git+https://git.example.com/test-repo",
},
};
// Create a query client for stories
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
// Interactive wrapper component for Storybook
const RemoteFormWrapper = (props: {
initialData: RemoteData;
disabled?: boolean;
machine: Machine;
field?: "targetHost" | "buildHost";
queryFn?: (params: {
name: string;
flake: { identifier: string };
field: string;
}) => Promise<RemoteDataSource | null>;
onSave?: (data: RemoteData) => void | Promise<void>;
showSave?: boolean;
}) => {
const [formData, setFormData] = createSignal(props.initialData);
const [saveMessage, setSaveMessage] = createSignal("");
return (
<QueryClientProvider client={queryClient}>
<div class="max-w-2xl p-6">
<h2 class="mb-6 text-2xl font-bold">Remote Configuration</h2>
<RemoteForm
onInput={(newData) => {
setFormData(newData);
// Log changes for Storybook actions
console.log("Form data changed:", newData);
}}
disabled={props.disabled}
machine={props.machine}
field={props.field}
queryFn={props.queryFn}
onSave={props.onSave}
showSave={props.showSave}
/>
{/* Display save message if present */}
{saveMessage() && (
<div class="mt-4 rounded bg-green-100 p-3 text-green-800">
{saveMessage()}
</div>
)}
{/* Display current form state */}
<details class="mt-8">
<summary class="cursor-pointer font-semibold">
Current Form Data (Debug)
</summary>
<pre class="mt-2 overflow-auto rounded bg-gray-100 p-4 text-sm">
{JSON.stringify(formData(), null, 2)}
</pre>
</details>
</div>
</QueryClientProvider>
);
};
const meta: Meta<typeof RemoteFormWrapper> = {
title: "Components/RemoteForm",
component: RemoteFormWrapper,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A form component for configuring remote SSH connection settings. Based on the Remote Python class with fields for address, authentication, and SSH options.",
},
},
},
argTypes: {
disabled: {
control: "boolean",
description: "Disable all form inputs",
},
machine: {
control: "object",
description: "Machine configuration for API queries",
},
field: {
control: "select",
options: ["targetHost", "buildHost"],
description: "Field type for API queries",
},
showSave: {
control: "boolean",
description: "Show or hide the save button",
},
onSave: {
action: "saved",
description: "Custom save handler function",
},
},
};
export default meta;
type Story = StoryObj<typeof RemoteFormWrapper>;
export const Empty: Story = {
args: {
initialData: defaultRemoteData,
disabled: false,
machine: sampleMachine,
queryFn: async () => ({
source: "inventory" as const,
data: {
address: "",
user: "",
command_prefix: "",
port: undefined,
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: 0,
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
},
}),
},
parameters: {
docs: {
description: {
story:
"Empty form with default values. All fields start empty except for boolean defaults.",
},
},
},
};
export const Populated: Story = {
args: {
initialData: sampleRemoteData,
disabled: false,
machine: sampleMachine,
queryFn: async () => ({
source: "inventory" as const,
data: sampleRemoteData,
}),
},
parameters: {
docs: {
description: {
story:
"Form pre-populated with sample data showing all field types in use.",
},
},
},
};
export const Disabled: Story = {
args: {
initialData: sampleRemoteData,
disabled: true,
machine: sampleMachine,
},
parameters: {
docs: {
description: {
story: "All form fields in disabled state. Useful for read-only views.",
},
},
},
};
// Advanced example with custom SSH options
const advancedRemoteData: RemoteData = {
address: "192.168.1.100",
user: "deploy",
command_prefix: "doas",
port: 2222,
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: 2,
verbose_ssh: true,
ssh_options: {
ConnectTimeout: "10",
ServerAliveInterval: "60",
ServerAliveCountMax: "3",
Compression: "yes",
TCPKeepAlive: "yes",
},
tor_socks: true,
};
export const NixManaged: Story = {
args: {
initialData: advancedRemoteData,
disabled: false,
machine: sampleMachine,
queryFn: async () => ({
source: "nix_machine" as const,
data: advancedRemoteData,
}),
},
parameters: {
docs: {
description: {
story:
"Configuration managed by Nix with advanced settings. Shows the locked state with unlock option.",
},
},
},
};
export const HiddenSaveButton: Story = {
args: {
initialData: sampleRemoteData,
disabled: false,
machine: sampleMachine,
showSave: false,
queryFn: async () => ({
source: "inventory" as const,
data: sampleRemoteData,
}),
},
parameters: {
docs: {
description: {
story:
"Form with the save button hidden. Useful when save functionality is handled externally.",
},
},
},
};

View File

@@ -0,0 +1,464 @@
import { createSignal, createEffect, JSX, Show } from "solid-js";
import { useQuery } from "@tanstack/solid-query";
import { callApi, SuccessQuery } from "@/src/api";
import { TextInput } from "@/src/Form/fields/TextInput";
import { SelectInput } from "@/src/Form/fields/Select";
import { FileInput } from "@/src/components/FileInput";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel, InputBase } from "@/src/components/inputBase";
import Icon from "@/src/components/icon";
import { Loader } from "@/src/components/v2/Loader/Loader";
import { Button } from "@/src/components/v2/Button/Button";
import Accordion from "@/src/components/accordion";
// Define the HostKeyCheck enum values with proper API mapping
export enum HostKeyCheck {
ASK = 0,
TOFU = 1,
IGNORE = 2,
}
// Export the API types for use in other components
export type { RemoteData, Machine, RemoteDataSource };
type RemoteDataSource = SuccessQuery<"get_host">["data"];
type MachineListData = SuccessQuery<"list_machines">["data"][string];
type RemoteData = NonNullable<RemoteDataSource>["data"];
// Machine type with flake for API calls
interface Machine {
name: string;
flake: {
identifier: string;
};
}
interface CheckboxInputProps {
label: JSX.Element;
value: boolean;
onInput: (value: boolean) => void;
help?: string;
class?: string;
disabled?: boolean;
}
function CheckboxInput(props: CheckboxInputProps) {
return (
<FieldLayout
label={
<InputLabel class="col-span-2" help={props.help}>
{props.label}
</InputLabel>
}
field={
<div class="col-span-10 flex items-center">
<input
type="checkbox"
checked={props.value}
onChange={(e) => props.onInput(e.currentTarget.checked)}
disabled={props.disabled}
class="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</div>
}
class={props.class}
/>
);
}
interface KeyValueInputProps {
label: JSX.Element;
value: Record<string, string>;
onInput: (value: Record<string, string>) => void;
help?: string;
class?: string;
disabled?: boolean;
}
function KeyValueInput(props: KeyValueInputProps) {
const [newKey, setNewKey] = createSignal("");
const [newValue, setNewValue] = createSignal("");
const addPair = () => {
const key = newKey().trim();
const value = newValue().trim();
if (key && value) {
props.onInput({ ...props.value, [key]: value });
setNewKey("");
setNewValue("");
}
};
const removePair = (key: string) => {
const { [key]: _, ...newObj } = props.value;
props.onInput(newObj);
};
return (
<FieldLayout
label={
<InputLabel class="col-span-2" help={props.help}>
{props.label}
</InputLabel>
}
field={
<div class="col-span-10 space-y-2">
{/* Existing pairs */}
{Object.entries(props.value).map(([key, value]) => (
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{key}:</span>
<span class="text-sm">{value}</span>
<button
type="button"
onClick={() => removePair(key)}
class="text-red-600 hover:text-red-800"
disabled={props.disabled}
>
×
</button>
</div>
))}
{/* Add new pair */}
<div class="flex gap-2">
<input
type="text"
placeholder="Key"
value={newKey()}
onInput={(e) => setNewKey(e.currentTarget.value)}
disabled={props.disabled}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<input
type="text"
placeholder="Value"
value={newValue()}
onInput={(e) => setNewValue(e.currentTarget.value)}
disabled={props.disabled}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<button
type="button"
onClick={addPair}
disabled={
props.disabled || !newKey().trim() || !newValue().trim()
}
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
}
class={props.class}
/>
);
}
interface RemoteFormProps {
onInput?: (value: RemoteData) => void;
machine: Machine;
field?: "targetHost" | "buildHost";
disabled?: boolean;
// Optional query function for testing/mocking
queryFn?: (params: {
name: string;
flake: {
identifier: string;
hash?: string | null;
store_path?: string | null;
};
field: string;
}) => Promise<RemoteDataSource | null>;
// Optional save handler for custom save behavior (e.g., in Storybook)
onSave?: (data: RemoteData) => void | Promise<void>;
// Show/hide save button
showSave?: boolean;
}
export function RemoteForm(props: RemoteFormProps) {
const [isLocked, setIsLocked] = createSignal(true);
const [source, setSource] = createSignal<"inventory" | "nix_machine" | null>(
null,
);
const [privateKeyFile, setPrivateKeyFile] = createSignal<File | undefined>();
const [formData, setFormData] = createSignal<RemoteData | null>(null);
const [isSaving, setIsSaving] = createSignal(false);
const hostKeyCheckOptions = [
{ value: "ASK", label: "Ask" },
{ value: "TOFU", label: "TOFU (Trust On First Use)" },
{ value: "IGNORE", label: "Ignore" },
];
// Helper function to convert enum name to numeric value
const getHostKeyCheckValue = (name: string): number => {
switch (name) {
case "ASK":
return HostKeyCheck.ASK;
case "TOFU":
return HostKeyCheck.TOFU;
case "IGNORE":
return HostKeyCheck.IGNORE;
default:
return HostKeyCheck.ASK;
}
};
// Helper function to convert numeric value to enum name
const getHostKeyCheckName = (value: number | undefined): string => {
switch (value) {
case HostKeyCheck.ASK:
return "ASK";
case HostKeyCheck.TOFU:
return "TOFU";
case HostKeyCheck.IGNORE:
return "IGNORE";
default:
return "ASK";
}
};
// Query host data when machine is provided
const hostQuery = useQuery(() => ({
queryKey: [
"get_host",
props.machine,
props.queryFn,
props.machine?.name,
props.machine?.flake,
props.field || "targetHost",
],
queryFn: async () => {
if (!props.machine) return null;
// Use custom query function if provided (for testing/mocking)
if (props.queryFn) {
return props.queryFn({
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
});
}
const result = await callApi("get_host", {
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
}).promise;
if (result.status === "error")
throw new Error("Failed to fetch host data");
return result.data;
},
enabled: !!props.machine,
}));
// Update form data and lock state when host data is loaded
createEffect(() => {
const hostData = hostQuery.data;
if (hostData?.data) {
setSource(hostData.source);
setIsLocked(hostData.source === "nix_machine");
setFormData(hostData.data);
props.onInput?.(hostData.data);
}
});
const isFormDisabled = () =>
props.disabled || (source() === "nix_machine" && isLocked());
const computedDisabled = isFormDisabled();
const updateFormData = (updates: Partial<RemoteData>) => {
const current = formData();
if (current) {
const updated = { ...current, ...updates };
setFormData(updated);
props.onInput?.(updated);
}
};
const handleSave = async () => {
const data = formData();
if (!data || isSaving()) return;
setIsSaving(true);
try {
if (props.onSave) {
await props.onSave(data);
} else {
// Default save behavior - could be extended with API call
console.log("Saving remote data:", data);
}
} catch (error) {
console.error("Error saving remote data:", error);
} finally {
setIsSaving(false);
}
};
return (
<div class="space-y-4">
<Show when={hostQuery.isLoading}>
<div class="flex justify-center p-8">
<Loader />
</div>
</Show>
<Show when={!hostQuery.isLoading && formData()}>
{/* Lock header for nix_machine source */}
<Show when={source() === "nix_machine"}>
<div class="flex items-center justify-between rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2">
<Icon icon="Warning" class="size-5 text-amber-600" />
<span class="text-sm font-medium text-amber-800">
Configuration managed by Nix
</span>
</div>
<button
type="button"
onClick={() => setIsLocked(!isLocked())}
class="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-amber-700 hover:bg-amber-100"
>
<Icon icon={isLocked() ? "Settings" : "Edit"} class="size-3" />
{isLocked() ? "Unlock to edit" : "Lock"}
</button>
</div>
</Show>
{/* Basic Connection Fields - Always Visible */}
<TextInput
label="User"
value={formData()?.user || ""}
inputProps={{
onInput: (e) => updateFormData({ user: e.currentTarget.value }),
}}
placeholder="username"
required
disabled={computedDisabled}
help="Username to connect as on the remote server"
/>
<TextInput
label="Address"
value={formData()?.address || ""}
inputProps={{
onInput: (e) => updateFormData({ address: e.currentTarget.value }),
}}
placeholder="hostname or IP address"
required
disabled={computedDisabled}
help="The hostname or IP address of the remote server"
/>
{/* Advanced Options - Collapsed by Default */}
<Accordion title="Advanced Options" class="mt-6">
<div class="space-y-4 pt-2">
<TextInput
label="Port"
value={formData()?.port?.toString() || ""}
inputProps={{
type: "number",
onInput: (e) => {
const value = e.currentTarget.value;
updateFormData({
port: value ? parseInt(value, 10) : undefined,
});
},
}}
placeholder="22"
disabled={computedDisabled}
help="SSH port (defaults to 22 if not specified)"
/>
<SelectInput
label="Host Key Check"
value={getHostKeyCheckName(formData()?.host_key_check)}
options={hostKeyCheckOptions}
selectProps={{
onInput: (e) =>
updateFormData({
host_key_check: getHostKeyCheckValue(
e.currentTarget.value,
) as 0 | 1 | 2 | 3,
}),
}}
disabled={computedDisabled}
helperText="How to handle host key verification"
/>
<Show when={typeof window !== "undefined"}>
<FieldLayout
label={
<InputLabel
class="col-span-2"
help="SSH private key file for authentication"
>
Private Key
</InputLabel>
}
field={
<div class="col-span-10">
<FileInput
name="private_key"
accept=".pem,.key,*"
value={privateKeyFile()}
onInput={(e) => {
const file = e.currentTarget.files?.[0];
setPrivateKeyFile(file);
updateFormData({
private_key: file?.name || null,
});
}}
onChange={() => void 0}
onBlur={() => void 0}
onClick={() => void 0}
ref={() => void 0}
placeholder={<>Click to select private key file</>}
class="w-full"
/>
</div>
}
/>
</Show>
<CheckboxInput
label="Forward Agent"
value={formData()?.forward_agent || false}
onInput={(value) => updateFormData({ forward_agent: value })}
disabled={computedDisabled}
help="Enable SSH agent forwarding"
/>
<KeyValueInput
label="SSH Options"
value={formData()?.ssh_options || {}}
onInput={(value) => updateFormData({ ssh_options: value })}
disabled={computedDisabled}
help="Additional SSH options as key-value pairs"
/>
<CheckboxInput
label="Tor SOCKS"
value={formData()?.tor_socks || false}
onInput={(value) => updateFormData({ tor_socks: value })}
disabled={computedDisabled}
help="Use Tor SOCKS proxy for SSH connection"
/>
</div>
</Accordion>
{/* Save Button */}
<Show when={props.showSave !== false}>
<div class="flex justify-end pt-4">
<Button
onClick={handleSave}
disabled={computedDisabled || isSaving()}
class="min-w-24"
>
{isSaving() ? "Saving..." : "Save"}
</Button>
</div>
</Show>
</Show>
</div>
);
}

View File

@@ -234,7 +234,7 @@ export const GhostPrimary: Story = {
play: Primary.play,
decorators: [
(Story) => (
<div class="bg-def-3 p-10">
<div class="p-10 bg-def-3">
<Story />
</div>
),

View File

@@ -8,6 +8,7 @@ import {
CreateMachine,
MachineDetails,
MachineListView,
MachineInstall,
} from "./routes/machines";
import { Layout } from "./layout/layout";
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
@@ -81,6 +82,12 @@ export const routes: AppRoute[] = [
hidden: true,
component: () => <VarsPage />,
},
{
path: "/:id/install",
label: "Install",
hidden: true,
component: () => <MachineInstall />,
},
],
},
{

View File

@@ -5,6 +5,7 @@ import {
required,
reset,
SubmitHandler,
ResponseData,
} from "@modular-forms/solid";
import toast from "solid-toast";
import { TextInput } from "@/src/Form/fields/TextInput";
@@ -18,7 +19,7 @@ type CreateForm = Meta & {
};
export const CreateClan = () => {
const [formStore, { Form, Field }] = createForm<CreateForm>({
const [formStore, { Form, Field }] = createForm<CreateForm, ResponseData>({
initialValues: {
name: "",
description: "",

View File

@@ -0,0 +1,264 @@
import { callApi, SuccessData } from "@/src/api";
import {
createForm,
getValue,
getValues,
setValue,
} from "@modular-forms/solid";
import { createSignal, Match, Switch } from "solid-js";
import { useClanContext } from "@/src/contexts/clan";
import { HWStep } from "../install/hardware-step";
import { DiskStep } from "../install/disk-step";
import { VarsStep } from "../install/vars-step";
import { SummaryStep } from "../install/summary-step";
import { InstallStepper } from "./InstallStepper";
import { InstallStepNavigation } from "./InstallStepNavigation";
import { InstallProgress } from "./InstallProgress";
import { DiskValues } from "../install/disk-step";
import { AllStepsValues } from "../types";
import { ResponseData } from "@modular-forms/solid";
type MachineData = SuccessData<"get_machine_details">;
type StepIdx = "1" | "2" | "3" | "4";
const INSTALL_STEPS = {
HARDWARE: "1" as StepIdx,
DISK: "2" as StepIdx,
VARS: "3" as StepIdx,
SUMMARY: "4" as StepIdx,
} as const;
const PROGRESS_DELAYS = {
INITIAL: 10 * 1000,
BUILD: 10 * 1000,
FORMAT: 10 * 1000,
COPY: 20 * 1000,
REBOOT: 10 * 1000,
} as const;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
interface InstallMachineProps {
name?: string;
machine: MachineData;
}
export function InstallMachine(props: InstallMachineProps) {
const { activeClanURI } = useClanContext();
const curr = activeClanURI();
const { name } = props;
if (!curr || !name) {
return <span>No Clan selected</span>;
}
const [formStore, { Form, Field }] = createForm<
AllStepsValues,
ResponseData
>();
const [isDone, setIsDone] = createSignal<boolean>(false);
const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
const [progressText, setProgressText] = createSignal<string>();
const [step, setStep] = createSignal<StepIdx>(INSTALL_STEPS.HARDWARE);
const nextStep = () => {
const currentStepNum = parseInt(step());
const nextStepNum = Math.min(currentStepNum + 1, 4);
setStep(nextStepNum.toString() as StepIdx);
};
const prevStep = () => {
const currentStepNum = parseInt(step());
const prevStepNum = Math.max(currentStepNum - 1, 1);
setStep(prevStepNum.toString() as StepIdx);
};
const isFirstStep = () => step() === INSTALL_STEPS.HARDWARE;
const isLastStep = () => step() === INSTALL_STEPS.SUMMARY;
const handleInstall = async (values: AllStepsValues) => {
const curr_uri = activeClanURI();
const diskValues = values["2"];
if (!curr_uri || !props.name) {
console.error("Missing clan URI or machine name");
return;
}
try {
setIsInstalling(true);
const shouldUpdateDisk =
JSON.stringify(props.machine.disk_schema?.placeholders) !==
JSON.stringify(diskValues.placeholders);
if (shouldUpdateDisk) {
setProgressText("Setting up disk ... (1/5)");
await callApi("set_machine_disk_schema", {
machine: {
flake: { identifier: curr_uri },
name: props.name,
},
placeholders: diskValues.placeholders,
schema_name: diskValues.schema,
force: true,
}).promise;
}
setProgressText("Installing machine ... (2/5)");
const targetHostResponse = await callApi("get_host", {
field: "targetHost",
flake: { identifier: curr_uri },
name: props.name,
}).promise;
if (
targetHostResponse.status === "error" ||
!targetHostResponse.data?.data
) {
throw new Error("No target host found for the machine");
}
const installPromise = callApi("install_machine", {
opts: {
machine: {
name: props.name,
flake: { identifier: curr_uri },
private_key: values.sshKey?.name,
},
},
target_host: targetHostResponse.data.data,
});
await sleep(PROGRESS_DELAYS.INITIAL);
setProgressText("Building machine ... (3/5)");
await sleep(PROGRESS_DELAYS.BUILD);
setProgressText("Formatting remote disk ... (4/5)");
await sleep(PROGRESS_DELAYS.FORMAT);
setProgressText("Copying system ... (5/5)");
await sleep(PROGRESS_DELAYS.COPY);
setProgressText("Rebooting remote system ...");
await sleep(PROGRESS_DELAYS.REBOOT);
const installResponse = await installPromise;
setIsDone(true);
} catch (error) {
console.error("Installation failed:", error);
setIsInstalling(false);
}
};
return (
<Switch
fallback={
<div class="flex min-h-screen flex-col gap-0">
<InstallStepper currentStep={step()} />
<Switch fallback={<div>Step not found</div>}>
<Match when={step() === INSTALL_STEPS.HARDWARE}>
<HWStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
handleNext={(data) => {
const prev = getValue(formStore, "1");
setValue(formStore, "1", { ...prev, ...data });
nextStep();
}}
initial={getValue(formStore, "1")}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
/>
}
/>
</Match>
<Match when={step() === INSTALL_STEPS.DISK}>
<DiskStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
/>
}
handleNext={(data) => {
const prev = getValue(formStore, "2");
setValue(formStore, "2", { ...prev, ...data });
nextStep();
}}
initial={
{
placeholders: props.machine.disk_schema?.placeholders || {
mainDisk: "",
},
schema: props.machine.disk_schema?.schema_name || "",
schema_name: props.machine.disk_schema?.schema_name || "",
...getValue(formStore, "2"),
initialized: !!props.machine.disk_schema,
} as DiskValues
}
/>
</Match>
<Match when={step() === INSTALL_STEPS.VARS}>
<VarsStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
handleNext={(data) => {
const prev = getValue(formStore, "3");
setValue(formStore, "3", { ...prev, ...data });
nextStep();
}}
initial={getValue(formStore, "3") || {}}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
/>
}
/>
</Match>
<Match when={step() === INSTALL_STEPS.SUMMARY}>
<SummaryStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
handleNext={() => nextStep()}
initial={getValues(formStore) as AllStepsValues}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
onInstall={() =>
handleInstall(getValues(formStore) as AllStepsValues)
}
/>
}
/>
</Match>
</Switch>
</div>
}
>
<Match when={isInstalling()}>
<InstallProgress
machineName={props.name || ""}
progressText={progressText()}
isDone={isDone()}
onCancel={() => setIsInstalling(false)}
/>
</Match>
</Switch>
);
}

View File

@@ -0,0 +1,65 @@
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/icon";
import { Typography } from "@/src/components/Typography";
const LoadingBar = () => (
<div
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
style={{
background: `repeating-linear-gradient(
45deg,
#ccc,
#ccc 8px,
#eee 8px,
#eee 16px
)`,
animation: "slide 25s linear infinite",
"background-size": "200% 100%",
}}
></div>
);
interface InstallProgressProps {
machineName: string;
progressText?: string;
isDone: boolean;
onCancel: () => void;
}
export function InstallProgress(props: InstallProgressProps) {
return (
<div class="flex h-96 w-[40rem] flex-col fg-inv-1">
<div class="flex w-full gap-1 p-4 bg-inv-4">
<Typography
color="inherit"
hierarchy="label"
size="default"
weight="medium"
>
Install:
</Typography>
<Typography
color="inherit"
hierarchy="label"
size="default"
weight="bold"
>
{props.machineName}
</Typography>
</div>
<div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1">
<Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" />
{props.isDone && <LoadingBar />}
<Typography
hierarchy="label"
size="default"
weight="medium"
color="inherit"
>
{props.progressText}
</Typography>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/icon";
interface InstallStepNavigationProps {
currentStep: string;
isFirstStep: boolean;
isLastStep: boolean;
onPrevious: () => void;
onNext?: () => void;
onInstall?: () => void;
}
export function InstallStepNavigation(props: InstallStepNavigationProps) {
return (
<div class="flex justify-between p-4">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={props.onPrevious}
disabled={props.isFirstStep}
>
Previous
</Button>
{props.isLastStep ? (
<Button startIcon={<Icon icon="Flash" />} onClick={props.onInstall}>
Install
</Button>
) : (
<Button endIcon={<Icon icon="ArrowRight" />} type="submit">
Next
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { For, Show } from "solid-js";
import cx from "classnames";
import Icon from "@/src/components/icon";
import { Typography } from "@/src/components/Typography";
const steps: Record<string, string> = {
"1": "Hardware detection",
"2": "Disk schema",
"3": "Credentials & Data",
"4": "Installation",
};
interface InstallStepperProps {
currentStep: string;
}
export function InstallStepper(props: InstallStepperProps) {
return (
<div class="flex items-center justify-evenly gap-2 border py-3 bg-def-3 border-def-2">
<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 === props.currentStep,
[cx("bg-def-4 fg-def-1")]: idx < props.currentStep,
}}
color="inherit"
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 >= props.currentStep}
fallback={<Icon icon="Checkmark" class="size-5" />}
>
{idx}
</Show>
</Typography>
<Typography
color="inherit"
hierarchy="label"
size="xs"
weight="medium"
class="text-center align-top fg-def-3"
classList={{
[cx("!fg-def-1")]: idx == props.currentStep,
}}
>
{label}
</Typography>
</div>
)}
</For>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/icon";
interface MachineActionsBarProps {
machineName: string;
onInstall: () => void;
onUpdate: () => void;
onCredentials: () => void;
}
export function MachineActionsBar(props: MachineActionsBarProps) {
return (
<div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
<div class="flex items-center gap-3">
<div class="button-group flex flex-shrink-0 min-w-0">
<Button
variant="light"
class="flex-1 min-w-0"
size="s"
onClick={props.onInstall}
endIcon={<Icon size={14} icon="Flash" />}
>
Install
</Button>
<Button
variant="light"
class="flex-1 min-w-0"
size="s"
onClick={props.onUpdate}
endIcon={<Icon size={14} icon="Update" />}
>
Update
</Button>
<Button
variant="light"
class="flex-1 min-w-0"
size="s"
onClick={props.onCredentials}
endIcon={<Icon size={14} icon="Folder" />}
>
Vars
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { callApi, SuccessData, OperationResponse } from "@/src/api";
import { createForm, getValue, ResponseData } from "@modular-forms/solid";
import { useNavigate } from "@solidjs/router";
import { useQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal } from "solid-js";
import { Button } from "@/src/components/Button/Button";
import { useClanContext } from "@/src/contexts/clan";
import { MachineAvatar } from "./MachineAvatar";
import toast from "solid-toast";
import { MachineActionsBar } from "./MachineActionsBar";
import { MachineGeneralFields } from "./MachineGeneralFields";
import { MachineHardwareInfo } from "./MachineHardwareInfo";
import { InstallMachine } from "./InstallMachine";
import { debug } from "console";
type DetailedMachineType = Extract<
OperationResponse<"get_machine_details">,
{ status: "success" }
>["data"];
interface MachineFormProps {
detailed: DetailedMachineType;
}
export function MachineForm(props: MachineFormProps) {
const { detailed } = props;
const [formStore, { Form, Field }] = createForm<
DetailedMachineType,
ResponseData
>({
initialValues: detailed,
});
const [isUpdating, setIsUpdating] = createSignal(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { activeClanURI } = useClanContext();
const handleSubmit = async (values: DetailedMachineType) => {
console.log("submitting", values);
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
await callApi("set_machine", {
machine: {
name: detailed.machine.name || "My machine",
flake: {
identifier: curr_uri,
},
},
update: {
...values.machine,
tags: Array.from(values.machine.tags || detailed.machine.tags || []),
},
}).promise;
await queryClient.invalidateQueries({
queryKey: [
curr_uri,
"machine",
detailed.machine.name,
"get_machine_details",
],
});
return null;
};
const generatorsQuery = useQuery(() => ({
queryKey: [activeClanURI(), detailed.machine.name, "generators"],
queryFn: async () => {
const machine_name = detailed.machine.name;
const base_dir = activeClanURI();
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
const handleUpdateButton = async () => {
await generatorsQuery.refetch();
if (
generatorsQuery.data?.some((generator) => generator.prompts?.length !== 0)
) {
navigate(`/machines/${detailed.machine.name || ""}/vars?action=update`);
} else {
handleUpdate();
}
};
const handleUpdate = async () => {
if (isUpdating()) {
return;
}
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
const machine = detailed.machine.name;
if (!machine) {
toast.error("Machine is required");
return;
}
const target = await callApi("get_host", {
field: "targetHost",
name: machine,
flake: {
identifier: curr_uri,
},
}).promise;
if (target.status === "error") {
toast.error("Failed to get target host");
return;
}
if (!target.data) {
toast.error("Target host is required");
return;
}
const target_host = target.data.data;
setIsUpdating(true);
const r = await callApi("deploy_machine", {
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
target_host: {
...target_host,
},
build_host: null,
}).promise.finally(() => {
setIsUpdating(false);
});
};
return (
<>
<div class="flex flex-col gap-6">
<MachineActionsBar
machineName={detailed.machine.name || ""}
onInstall={() =>
navigate(`/machines/${detailed.machine.name || ""}/install`)
}
onUpdate={handleUpdateButton}
onCredentials={() =>
navigate(`/machines/${detailed.machine.name || ""}/vars`)
}
/>
<div class="p-4">
<span class="mb-2 flex w-full justify-center">
<MachineAvatar name={detailed.machine.name || ""} />
</span>
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<MachineGeneralFields formStore={formStore} />
<MachineHardwareInfo formStore={formStore} />
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
<Button
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Update edits
</Button>
</footer>
</Form>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,82 @@
import {
Field,
FieldValues,
FormStore,
ResponseData,
FieldStore,
FieldElementProps,
FieldPath,
} from "@modular-forms/solid";
import { TextInput } from "@/src/Form/fields/TextInput";
import { Typography } from "@/src/components/Typography";
import { TagList } from "@/src/components/TagList/TagList";
import Fieldset from "@/src/Form/fieldset";
import { SuccessData } from "@/src/api";
type MachineData = SuccessData<"get_machine_details">;
interface MachineGeneralFieldsProps {
formStore: FormStore<MachineData, ResponseData>;
}
export function MachineGeneralFields(props: MachineGeneralFieldsProps) {
const { formStore } = props;
return (
<Fieldset legend="General">
<Field name="machine.name" of={formStore}>
{(
field: FieldStore<MachineData, "machine.name">,
fieldProps: FieldElementProps<MachineData, "machine.name">,
) => {
return (
<TextInput
inputProps={fieldProps}
label="Name"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
);
}}
</Field>
<Field name="machine.description" of={formStore}>
{(
field: FieldStore<MachineData, "machine.description">,
fieldProps: FieldElementProps<MachineData, "machine.description">,
) => (
<TextInput
inputProps={fieldProps}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
/>
)}
</Field>
<Field name="machine.tags" of={formStore} type="string[]">
{(
field: FieldStore<MachineData, "machine.tags">,
fieldProps: FieldElementProps<MachineData, "machine.tags">,
) => (
<div class="grid grid-cols-10 items-center">
<Typography
hierarchy="label"
size="default"
weight="bold"
class="col-span-5"
>
Tags{" "}
</Typography>
<div class="col-span-5 justify-self-end">
<TagList values={[...(field.value || [])].sort()} />
</div>
</div>
)}
</Field>
</Fieldset>
);
}

View File

@@ -0,0 +1,55 @@
import {
Field,
FieldValues,
FormStore,
ResponseData,
FieldStore,
FieldElementProps,
} from "@modular-forms/solid";
import { Typography } from "@/src/components/Typography";
import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout";
import Fieldset from "@/src/Form/fieldset";
import { SuccessData } from "@/src/api";
type MachineData = SuccessData<"get_machine_details">;
interface MachineHardwareInfoProps {
formStore: FormStore<MachineData, ResponseData>;
}
export function MachineHardwareInfo(props: MachineHardwareInfoProps) {
const { formStore } = props;
return (
<Typography hierarchy={"body"} size={"s"}>
<Fieldset>
<Field name="hw_config" of={formStore}>
{(
field: FieldStore<MachineData, "hw_config">,
fieldProps: FieldElementProps<MachineData, "hw_config">,
) => (
<FieldLayout
label={<InputLabel>Hardware Configuration</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
<hr />
<Field name="disk_schema.schema_name" of={formStore}>
{(
field: FieldStore<MachineData, "disk_schema.schema_name">,
fieldProps: FieldElementProps<
MachineData,
"disk_schema.schema_name"
>,
) => (
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
</Fieldset>
</Typography>
);
}

View File

@@ -0,0 +1,9 @@
export { MachineActionsBar } from "./MachineActionsBar";
export { MachineAvatar } from "./MachineAvatar";
export { MachineForm } from "./MachineForm";
export { MachineGeneralFields } from "./MachineGeneralFields";
export { MachineHardwareInfo } from "./MachineHardwareInfo";
export { InstallMachine } from "./InstallMachine";
export { InstallProgress } from "./InstallProgress";
export { InstallStepper } from "./InstallStepper";
export { InstallStepNavigation } from "./InstallStepNavigation";

View File

@@ -1,804 +0,0 @@
import { callApi, SuccessData } from "@/src/api";
import {
createForm,
FieldValues,
getValue,
getValues,
setValue,
} from "@modular-forms/solid";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { useQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import Accordion from "@/src/components/accordion";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header";
import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout";
import { Modal } from "@/src/components/modal";
import { Typography } from "@/src/components/Typography";
import { HardwareValues, HWStep } from "./install/hardware-step";
import { DiskStep, DiskValues } from "./install/disk-step";
import { SummaryStep } from "./install/summary-step";
import cx from "classnames";
import { VarsStep, VarsValues } from "./install/vars-step";
import Fieldset from "@/src/Form/fieldset";
import {
type FileDialogOptions,
FileSelectorField,
} from "@/src/components/fileSelect";
import { useClanContext } from "@/src/contexts/clan";
import { TagList } from "@/src/components/TagList/TagList";
type MachineFormInterface = MachineData & {
sshKey?: File;
disk?: string;
};
type MachineData = SuccessData<"get_machine_details">;
const steps: Record<StepIdx, string> = {
"1": "Hardware detection",
"2": "Disk schema",
"3": "Credentials & Data",
"4": "Installation",
};
type StepIdx = keyof AllStepsValues;
export interface AllStepsValues extends FieldValues {
"1": HardwareValues;
"2": DiskValues;
"3": VarsValues;
"4": NonNullable<unknown>;
sshKey?: File;
}
const LoadingBar = () => (
<div
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
style={{
background: `repeating-linear-gradient(
45deg,
#ccc,
#ccc 8px,
#eee 8px,
#eee 16px
)`,
animation: "slide 25s linear infinite",
"background-size": "200% 100%",
}}
></div>
);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface InstallMachineProps {
name?: string;
machine: MachineData;
}
const InstallMachine = (props: InstallMachineProps) => {
const { activeClanURI } = useClanContext();
const curr = activeClanURI();
const { name } = props;
if (!curr || !name) {
return <span>No Clan selected</span>;
}
const [formStore, { Form, Field }] = createForm<AllStepsValues>();
const [isDone, setIsDone] = createSignal<boolean>(false);
const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
const [progressText, setProgressText] = createSignal<string>();
const handleInstall = async (values: AllStepsValues) => {
console.log("Installing", values);
const curr_uri = activeClanURI();
const target = values["1"].target;
const diskValues = values["2"];
if (!curr_uri) {
return;
}
if (!props.name) {
return;
}
setIsInstalling(true);
// props.machine.disk_
const shouldRunDisk =
JSON.stringify(props.machine.disk_schema?.placeholders) !==
JSON.stringify(diskValues.placeholders);
if (shouldRunDisk) {
setProgressText("Setting up disk ... (1/5)");
const disk_response = await callApi("set_machine_disk_schema", {
machine: {
flake: { identifier: curr_uri },
name: props.name,
},
placeholders: diskValues.placeholders,
schema_name: diskValues.schema,
force: true,
}).promise;
}
setProgressText("Installing machine ... (2/5)");
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: curr_uri },
name: props.name,
}).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
return;
}
if (target_host.data === null) {
console.error("No target host found for the machine");
return;
}
if (!target_host.data!.data) {
console.error("No target host found for the machine");
return;
}
const installPromise = callApi("install_machine", {
opts: {
machine: {
name: props.name,
flake: {
identifier: curr_uri,
},
private_key: values.sshKey?.name,
},
password: "",
},
target_host: target_host.data!.data,
});
// Next step
await sleep(10 * 1000);
setProgressText("Building machine ... (3/5)");
await sleep(10 * 1000);
setProgressText("Formatting remote disk ... (4/5)");
await sleep(10 * 1000);
setProgressText("Copying system ... (5/5)");
await sleep(20 * 1000);
setProgressText("Rebooting remote system ... ");
await sleep(10 * 1000);
const installResponse = await installPromise;
};
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 p-4">
<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 (
<Switch
fallback={
<Form
onSubmit={handleInstall}
class="relative top-0 flex h-full flex-col gap-0"
>
{/* Register each step as form field */}
{/* @ts-expect-error: object type is not statically supported */}
<Field name="1">{(field, fieldProps) => <></>}</Field>
{/* @ts-expect-error: object type is not statically supported */}
<Field name="2">{(field, fieldProps) => <></>}</Field>
{/* Modal Header */}
<div class="select-none px-6 py-2">
<Typography hierarchy="label" size="default">
Install:{" "}
</Typography>
<Typography hierarchy="label" size="default" weight="bold">
{props.name}
</Typography>
</div>
{/* Stepper header */}
<div class="flex items-center justify-evenly gap-2 border py-3 bg-def-3 border-def-2">
<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(),
}}
color="inherit"
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
color="inherit"
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>
<Switch fallback={"Undefined content. This Step seems to not exist."}>
<Match when={step() === "1"}>
<HWStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
handleNext={(data) => {
const prev = getValue(formStore, "1");
setValue(formStore, "1", { ...prev, ...data });
handleNext();
}}
initial={
getValue(formStore, "1") || {
target: props.machine.machine.deploy?.targetHost || "",
report: false,
}
}
footer={<Footer />}
/>
</Match>
<Match when={step() === "2"}>
<DiskStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
footer={<Footer />}
handleNext={(data) => {
const prev = getValue(formStore, "2");
setValue(formStore, "2", { ...prev, ...data });
handleNext();
}}
// @ts-expect-error: The placeholder type is to wide
initial={{
...props.machine.disk_schema,
...getValue(formStore, "2"),
initialized: !!props.machine.disk_schema,
}}
/>
</Match>
<Match when={step() === "3"}>
<VarsStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
handleNext={(data) => {
const prev = getValue(formStore, "3");
setValue(formStore, "3", { ...prev, ...data });
handleNext();
}}
initial={getValue(formStore, "3") || {}}
footer={<Footer />}
/>
</Match>
<Match when={step() === "4"}>
<SummaryStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
handleNext={() => handleNext()}
// @ts-expect-error: This cannot be known.
initial={getValues(formStore)}
footer={
<div class="flex justify-between p-4">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={handlePrev}
disabled={step() === "1"}
>
Previous
</Button>
<Button startIcon={<Icon icon="Flash" />}>Install</Button>
</div>
}
/>
</Match>
</Switch>
</Form>
}
>
<Match when={isInstalling()}>
<div class="flex h-96 w-[40rem] flex-col fg-inv-1">
<div class="flex w-full gap-1 p-4 bg-inv-4">
<Typography
color="inherit"
hierarchy="label"
size="default"
weight="medium"
>
Install:
</Typography>
<Typography
color="inherit"
hierarchy="label"
size="default"
weight="bold"
>
{props.name}
</Typography>
</div>
<div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1">
<Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" />
{isDone() && <LoadingBar />}
<Typography
hierarchy="label"
size="default"
weight="medium"
color="inherit"
>
{progressText()}
</Typography>
<Button onClick={() => setIsInstalling(false)}>Cancel</Button>
</div>
</div>
</Match>
</Switch>
);
};
interface MachineDetailsProps {
initialData: MachineData;
}
const MachineForm = (props: MachineDetailsProps) => {
const [formStore, { Form, Field }] =
// TODO: retrieve the correct initial values from API
createForm<MachineFormInterface>({
initialValues: props.initialData,
});
const targetHost = () => getValue(formStore, "machine.deploy.targetHost");
const machineName = () =>
getValue(formStore, "machine.name") || props.initialData.machine.name;
const [installModalOpen, setInstallModalOpen] = createSignal(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { activeClanURI } = useClanContext();
const handleSubmit = async (values: MachineFormInterface) => {
console.log("submitting", values);
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
const machine_response = await callApi("set_machine", {
machine: {
name: props.initialData.machine.name || "My machine",
flake: {
identifier: curr_uri,
},
},
update: {
...values.machine,
// TODO: Remove this workaround
tags: Array.from(
values.machine.tags || props.initialData.machine.tags || [],
),
},
}).promise;
await queryClient.invalidateQueries({
queryKey: [curr_uri, "machine", machineName(), "get_machine_details"],
});
return null;
};
const generatorsQuery = useQuery(() => ({
queryKey: [activeClanURI(), machineName(), "generators"],
queryFn: async () => {
const machine_name = machineName();
const base_dir = activeClanURI();
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
const handleUpdateButton = async () => {
await generatorsQuery.refetch();
if (
generatorsQuery.data?.some((generator) => generator.prompts?.length !== 0)
) {
navigate(`/machines/${machineName()}/vars?action=update`);
} else {
handleUpdate();
}
};
const [isUpdating, setIsUpdating] = createSignal(false);
const handleUpdate = async () => {
if (isUpdating()) {
return;
}
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
const machine = machineName();
if (!machine) {
toast.error("Machine is required");
return;
}
const target = targetHost();
const active_clan = activeClanURI();
if (!active_clan) {
console.error("No active clan selected");
return;
}
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: machine,
}).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
return;
}
if (target_host.data === null) {
console.error("No target host found for the machine");
return;
}
if (!target_host.data!.data) {
console.error("No target host found for the machine");
return;
}
const build_host = await callApi("get_host", {
field: "buildHost",
flake: { identifier: active_clan },
name: machine,
}).promise;
if (build_host.status == "error") {
console.error("No target host found for the machine");
return;
}
if (build_host.data === null) {
console.error("No target host found for the machine");
return;
}
setIsUpdating(true);
const r = await callApi("deploy_machine", {
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
target_host: target_host.data!.data,
build_host: build_host.data!.data,
}).promise;
};
createEffect(() => {
const action = searchParams.action;
console.log({ action });
if (action === "update") {
setSearchParams({ action: undefined });
handleUpdate();
}
});
return (
<>
<div class="flex flex-col gap-6">
<div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
<div class="flex items-center gap-3">
<div class="w-fit" data-tip="Machine must be online">
{/* <Button
class="w-full"
size="s"
// disabled={!online()}
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon icon="Flash" />}
>
Install
</Button> */}
</div>
{/* <Typography hierarchy="label" size="default">
Installs the system for the first time. Used to bootstrap the
remote device.
</Typography> */}
</div>
<div class="flex items-center gap-3">
<div class="button-group flex">
<Button
variant="light"
class="w-full"
size="s"
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon size={14} icon="Flash" />}
>
Install
</Button>
<Button
variant="light"
class="w-full"
size="s"
onClick={() => handleUpdateButton()}
endIcon={<Icon size={12} icon="Update" />}
>
Update
</Button>
<Button
variant="light"
class="w-full"
size="s"
onClick={() => {
navigate(`/machines/${machineName()}/vars`);
}}
endIcon={<Icon size={12} icon="Folder" />}
>
Credentials
</Button>
</div>
<div class=" w-fit" data-tip="Machine must be online"></div>
{/* <Typography hierarchy="label" size="default">
Update the system if changes should be synced after the
installation process.
</Typography> */}
</div>
</div>
<div class="p-4">
<span class="mb-2 flex w-full justify-center">
<MachineAvatar name={machineName()} />
</span>
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Fieldset legend="General">
<Field name="machine.name">
{(field, props) => (
<TextInput
inputProps={props}
label="Name"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<TextInput
inputProps={props}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
/>
)}
</Field>
<Field name="machine.tags" type="string[]">
{(field, props) => (
<div class="grid grid-cols-10 items-center">
<Typography
hierarchy="label"
size="default"
weight="bold"
class="col-span-5"
>
Tags{" "}
</Typography>
<div class="col-span-5 justify-self-end">
{/* alphabetically sort the tags */}
<TagList values={[...(field.value || [])].sort()} />
</div>
</div>
)}
</Field>
</Fieldset>
<Typography hierarchy={"body"} size={"s"}>
<Fieldset legend="Hardware">
<Field name="hw_config">
{(field, props) => (
<FieldLayout
label={<InputLabel>Hardware Configuration</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
<hr />
<Field name="disk_schema.schema_name">
{(field, props) => (
<>
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</>
)}
</Field>
</Fieldset>
</Typography>
<Accordion title="Connection Settings">
<Fieldset>
<Field name="machine.deploy.targetHost">
{(field, props) => (
<TextInput
inputProps={props}
label="Target Host"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<FileSelectorField
Field={Field}
of={Array<File>}
multiple={true}
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
label="SSH Private Key"
description="Provide your SSH private key for secure, passwordless connections."
fileDialogOptions={
{
title: "Select SSH Keys",
initial_folder: "~/.ssh",
} as FileDialogOptions
}
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
// e.g. validate={[required("At least one SSH key is required.")]}
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
/>
</Fieldset>
</Accordion>
{
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
<Button
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Update edits
</Button>
</footer>
}
</Form>
</div>
</div>
<Modal
title={`Install machine`}
open={installModalOpen()}
handleClose={() => setInstallModalOpen(false)}
class="min-w-[600px]"
>
<InstallMachine name={machineName()} machine={props.initialData} />
</Modal>
</>
);
};
export const MachineDetails = () => {
const params = useParams();
const { activeClanURI } = useClanContext();
const genericQuery = useQuery(() => ({
queryKey: [activeClanURI(), "machine", params.id, "get_machine_details"],
queryFn: async () => {
const curr = activeClanURI();
if (curr) {
const result = await callApi("get_machine_details", {
machine: {
flake: {
identifier: curr,
},
name: params.id,
},
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
return (
<>
<Header title={`${params.id} machine`} showBack />
<Show when={genericQuery.data} fallback={<span class=""></span>}>
{(data) => (
<>
<MachineForm initialData={data()} />
</>
)}
</Show>
</>
);
};

View File

@@ -1,3 +1,5 @@
export * from "./details";
export * from "./create";
export * from "./list";
export * from "./machine-details";
export * from "./machine-create";
export * from "./machines-list";
export * from "./machine-install";
export * from "./types";

View File

@@ -11,7 +11,6 @@ import { StepProps } from "./hardware-step";
import { SelectInput } from "@/src/Form/fields/Select";
import { Typography } from "@/src/components/Typography";
import { Group } from "@/src/components/group";
import { useContext } from "corvu/dialog";
export interface DiskValues extends FieldValues {
placeholders: {
@@ -49,8 +48,6 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
},
}));
const modalContext = useContext();
return (
<>
<Form
@@ -116,7 +113,6 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
placeholder="Select a disk"
selectProps={fieldProps}
required={!props.initial?.initialized}
portalRef={modalContext.contentRef}
/>
)}
</Field>

View File

@@ -14,20 +14,21 @@ import {
validate,
} from "@modular-forms/solid";
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
import { TextInput } from "@/src/Form/fields";
import { createQuery } from "@tanstack/solid-query";
import { useQuery } from "@tanstack/solid-query";
import { Badge } from "@/src/components/badge";
import { Group } from "@/src/components/group";
import {
type FileDialogOptions,
FileSelectorField,
} from "@/src/components/fileSelect";
import { useClanContext } from "@/src/contexts/clan";
import {
RemoteForm,
type RemoteData,
HostKeyCheck,
} from "@/src/components/RemoteForm";
import { ac } from "vitest/dist/chunks/reporters.d.C-cu31ET";
export type HardwareValues = FieldValues & {
report: boolean;
target: string;
sshKey?: File;
remoteData: RemoteData;
};
export interface StepProps<T> {
@@ -42,17 +43,38 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
initialValues: (props.initial as HardwareValues) || {},
});
// Initialize remote data from existing target or create new default
const [remoteData, setRemoteData] = createSignal<RemoteData>({
address: props.initial?.target || "",
user: "root",
command_prefix: "sudo",
port: 22,
forward_agent: false,
host_key_check: 1, // 0 = ASK
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
});
const handleSubmit: SubmitHandler<HardwareValues> = async (values, event) => {
console.log("Submit Hardware", { values });
const valid = await validate(formStore);
console.log("Valid", valid);
if (!valid) return;
props.handleNext(values);
// Include remote data in the values
const submitValues = {
...values,
remoteData: remoteData(),
target: remoteData().address, // Keep target for backward compatibility
};
props.handleNext(submitValues);
};
const [isGenerating, setIsGenerating] = createSignal(false);
const hwReportQuery = createQuery(() => ({
const hwReportQuery = useQuery(() => ({
queryKey: [props.dir, props.machine_id, "hw_report"],
queryFn: async () => {
const result = await callApi("show_machine_hardware_config", {
@@ -78,15 +100,9 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
const { activeClanURI } = useClanContext();
const generateReport = async (e: Event) => {
const curr_uri = activeClanURI();
if (!curr_uri) return;
await validate(formStore, "target");
const target = getValue(formStore, "target");
const sshFile = getValue(formStore, "sshKey") as File | undefined;
if (!target) {
console.error("Target is not set");
const currentRemoteData = remoteData();
if (!currentRemoteData.address) {
console.error("Target address is not set");
return;
}
@@ -121,9 +137,8 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
opts: {
machine: {
name: props.machine_id,
private_key: sshFile?.name,
flake: {
identifier: curr_uri,
identifier: active_clan,
},
},
backend: "nixos-facter",
@@ -142,35 +157,26 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
<div class="flex h-full flex-col gap-6 p-4">
<Group>
<Field name="target" validate={required("Target must be provided")}>
<RemoteForm
showSave={false}
machine={{
name: props.machine_id,
flake: {
identifier: props.dir,
},
}}
field="targetHost"
/>
{/* Hidden field for form validation */}
<Field name="target">
{(field, fieldProps) => (
<TextInput
error={field.error}
variant="ghost"
label="Target ip"
value={field.value || ""}
inputProps={fieldProps}
required
<input
{...fieldProps}
type="hidden"
value={remoteData().address}
/>
)}
</Field>
<FileSelectorField
Field={Field}
of={File}
multiple={false}
name="sshKey" // Corresponds to FlashFormValues.sshKeys
label="SSH Private Key"
description="Provide your SSH private key for secure, passwordless connections."
fileDialogOptions={
{
title: "Select SSH Keys",
initial_folder: "~/.ssh",
} as FileDialogOptions
}
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
// e.g. validate={[required("At least one SSH key is required.")]}
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
/>
</Group>
<Group>
<Field

View File

@@ -3,7 +3,7 @@ import { Typography } from "@/src/components/Typography";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase";
import { Group, Section, SectionHeader } from "@/src/components/group";
import { AllStepsValues } from "../details";
import { AllStepsValues } from "../types";
import { Badge } from "@/src/components/badge";
import Icon from "@/src/components/icon";

View File

@@ -8,7 +8,7 @@ import { useNavigate } from "@solidjs/router";
import { useQueryClient } from "@tanstack/solid-query";
import { Match, Switch } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { MachineAvatar } from "./components";
import { DynForm } from "@/src/Form/form";
import Fieldset from "@/src/Form/fieldset";
import Accordion from "@/src/components/accordion";

View File

@@ -0,0 +1,44 @@
import { callApi } from "@/src/api";
import { useParams } from "@solidjs/router";
import { useQuery } from "@tanstack/solid-query";
import { Show } from "solid-js";
import { Header } from "@/src/layout/header";
import { useClanContext } from "@/src/contexts/clan";
import { MachineForm } from "./components/MachineForm";
export const MachineDetails = () => {
const params = useParams();
const { activeClanURI } = useClanContext();
const genericQuery = useQuery(() => ({
queryKey: [activeClanURI(), "machine", params.id, "get_machine_details"],
queryFn: async () => {
const curr = activeClanURI();
if (curr) {
const result = await callApi("get_machine_details", {
machine: {
flake: {
identifier: curr,
},
name: params.id,
},
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
return (
<>
<Header title={`${params.id} machine`} showBack />
<Show
when={genericQuery.data}
fallback={<div class="p-4">Loading...</div>}
>
{(data) => <MachineForm detailed={data()} />}
</Show>
</>
);
};

View File

@@ -0,0 +1,44 @@
import { callApi } from "@/src/api";
import { useParams } from "@solidjs/router";
import { useQuery } from "@tanstack/solid-query";
import { Show } from "solid-js";
import { Header } from "@/src/layout/header";
import { useClanContext } from "@/src/contexts/clan";
import { InstallMachine } from "./components";
export const MachineInstall = () => {
const params = useParams();
const { activeClanURI } = useClanContext();
const genericQuery = useQuery(() => ({
queryKey: [activeClanURI(), "machine", params.id, "get_machine_details"],
queryFn: async () => {
const curr = activeClanURI();
if (curr) {
const result = await callApi("get_machine_details", {
machine: {
flake: {
identifier: curr,
},
name: params.id,
},
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
return (
<>
<Header title={`Install ${params.id}`} showBack />
<Show
when={genericQuery.data}
fallback={<div class="p-4">Loading...</div>}
>
{(data) => <InstallMachine name={params.id} machine={data()} />}
</Show>
</>
);
};

View File

@@ -0,0 +1,12 @@
import { FieldValues } from "@modular-forms/solid";
import { HardwareValues } from "./install/hardware-step";
import { DiskValues } from "./install/disk-step";
import { VarsValues } from "./install/vars-step";
export interface AllStepsValues extends FieldValues {
"1": HardwareValues;
"2": DiskValues;
"3": VarsValues;
"4": NonNullable<unknown>;
sshKey?: File;
}