+
),
diff --git a/pkgs/clan-app/ui/src/index.tsx b/pkgs/clan-app/ui/src/index.tsx
index 80f110e46..8cbedad85 100644
--- a/pkgs/clan-app/ui/src/index.tsx
+++ b/pkgs/clan-app/ui/src/index.tsx
@@ -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: () =>
,
},
+ {
+ path: "/:id/install",
+ label: "Install",
+ hidden: true,
+ component: () =>
,
+ },
],
},
{
diff --git a/pkgs/clan-app/ui/src/routes/clans/create.tsx b/pkgs/clan-app/ui/src/routes/clans/create.tsx
index 002109c27..67899a1f1 100644
--- a/pkgs/clan-app/ui/src/routes/clans/create.tsx
+++ b/pkgs/clan-app/ui/src/routes/clans/create.tsx
@@ -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
({
+ const [formStore, { Form, Field }] = createForm({
initialValues: {
name: "",
description: "",
diff --git a/pkgs/clan-app/ui/src/routes/machines/components/InstallMachine.tsx b/pkgs/clan-app/ui/src/routes/machines/components/InstallMachine.tsx
new file mode 100644
index 000000000..4a5987f5e
--- /dev/null
+++ b/pkgs/clan-app/ui/src/routes/machines/components/InstallMachine.tsx
@@ -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 No Clan selected;
+ }
+
+ const [formStore, { Form, Field }] = createForm<
+ AllStepsValues,
+ ResponseData
+ >();
+ const [isDone, setIsDone] = createSignal(false);
+ const [isInstalling, setIsInstalling] = createSignal(false);
+ const [progressText, setProgressText] = createSignal();
+ const [step, setStep] = createSignal(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 (
+
+
+ Step not found }>
+
+
+
+ Install:
+
+
+ {props.machineName}
+
+
+
+
+ {props.isDone && }
+
+ {props.progressText}
+
+
+
+
+ );
+}
diff --git a/pkgs/clan-app/ui/src/routes/machines/components/InstallStepNavigation.tsx b/pkgs/clan-app/ui/src/routes/machines/components/InstallStepNavigation.tsx
new file mode 100644
index 000000000..2dbef7444
--- /dev/null
+++ b/pkgs/clan-app/ui/src/routes/machines/components/InstallStepNavigation.tsx
@@ -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 (
+