clan-app: Untangle Machine Details into separate components. Makes it non functional for now.
This commit is contained in:
@@ -241,9 +241,9 @@ export function SelectInput(props: SelectInputpProps) {
|
|||||||
top: `${position.y ?? 0}px`,
|
top: `${position.y ?? 0}px`,
|
||||||
left: `${position.x ?? 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 ...."}>
|
<Show when={!props.loading} fallback={"Loading ...."}>
|
||||||
<For each={props.options}>
|
<For each={props.options}>
|
||||||
{(opt) => (
|
{(opt) => (
|
||||||
|
|||||||
287
pkgs/clan-app/ui/src/components/RemoteForm.stories.tsx
Normal file
287
pkgs/clan-app/ui/src/components/RemoteForm.stories.tsx
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
464
pkgs/clan-app/ui/src/components/RemoteForm.tsx
Normal file
464
pkgs/clan-app/ui/src/components/RemoteForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@ export const GhostPrimary: Story = {
|
|||||||
play: Primary.play,
|
play: Primary.play,
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<div class="bg-def-3 p-10">
|
<div class="p-10 bg-def-3">
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CreateMachine,
|
CreateMachine,
|
||||||
MachineDetails,
|
MachineDetails,
|
||||||
MachineListView,
|
MachineListView,
|
||||||
|
MachineInstall,
|
||||||
} from "./routes/machines";
|
} from "./routes/machines";
|
||||||
import { Layout } from "./layout/layout";
|
import { Layout } from "./layout/layout";
|
||||||
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
|
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
|
||||||
@@ -81,6 +82,12 @@ export const routes: AppRoute[] = [
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
component: () => <VarsPage />,
|
component: () => <VarsPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/:id/install",
|
||||||
|
label: "Install",
|
||||||
|
hidden: true,
|
||||||
|
component: () => <MachineInstall />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
required,
|
required,
|
||||||
reset,
|
reset,
|
||||||
SubmitHandler,
|
SubmitHandler,
|
||||||
|
ResponseData,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||||
@@ -18,7 +19,7 @@ type CreateForm = Meta & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CreateClan = () => {
|
export const CreateClan = () => {
|
||||||
const [formStore, { Form, Field }] = createForm<CreateForm>({
|
const [formStore, { Form, Field }] = createForm<CreateForm, ResponseData>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
pkgs/clan-app/ui/src/routes/machines/components/MachineForm.tsx
Normal file
192
pkgs/clan-app/ui/src/routes/machines/components/MachineForm.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
pkgs/clan-app/ui/src/routes/machines/components/index.ts
Normal file
9
pkgs/clan-app/ui/src/routes/machines/components/index.ts
Normal 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";
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from "./details";
|
export * from "./machine-details";
|
||||||
export * from "./create";
|
export * from "./machine-create";
|
||||||
export * from "./list";
|
export * from "./machines-list";
|
||||||
|
export * from "./machine-install";
|
||||||
|
export * from "./types";
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { StepProps } from "./hardware-step";
|
|||||||
import { SelectInput } from "@/src/Form/fields/Select";
|
import { SelectInput } from "@/src/Form/fields/Select";
|
||||||
import { Typography } from "@/src/components/Typography";
|
import { Typography } from "@/src/components/Typography";
|
||||||
import { Group } from "@/src/components/group";
|
import { Group } from "@/src/components/group";
|
||||||
import { useContext } from "corvu/dialog";
|
|
||||||
|
|
||||||
export interface DiskValues extends FieldValues {
|
export interface DiskValues extends FieldValues {
|
||||||
placeholders: {
|
placeholders: {
|
||||||
@@ -49,8 +48,6 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const modalContext = useContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form
|
<Form
|
||||||
@@ -116,7 +113,6 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
|
|||||||
placeholder="Select a disk"
|
placeholder="Select a disk"
|
||||||
selectProps={fieldProps}
|
selectProps={fieldProps}
|
||||||
required={!props.initial?.initialized}
|
required={!props.initial?.initialized}
|
||||||
portalRef={modalContext.contentRef}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -14,20 +14,21 @@ import {
|
|||||||
validate,
|
validate,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
|
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
|
||||||
import { TextInput } from "@/src/Form/fields";
|
import { useQuery } from "@tanstack/solid-query";
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
|
||||||
import { Badge } from "@/src/components/badge";
|
import { Badge } from "@/src/components/badge";
|
||||||
import { Group } from "@/src/components/group";
|
import { Group } from "@/src/components/group";
|
||||||
import {
|
|
||||||
type FileDialogOptions,
|
|
||||||
FileSelectorField,
|
|
||||||
} from "@/src/components/fileSelect";
|
|
||||||
import { useClanContext } from "@/src/contexts/clan";
|
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 & {
|
export type HardwareValues = FieldValues & {
|
||||||
report: boolean;
|
report: boolean;
|
||||||
target: string;
|
target: string;
|
||||||
sshKey?: File;
|
remoteData: RemoteData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface StepProps<T> {
|
export interface StepProps<T> {
|
||||||
@@ -42,17 +43,38 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
|||||||
initialValues: (props.initial as 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) => {
|
const handleSubmit: SubmitHandler<HardwareValues> = async (values, event) => {
|
||||||
console.log("Submit Hardware", { values });
|
console.log("Submit Hardware", { values });
|
||||||
const valid = await validate(formStore);
|
const valid = await validate(formStore);
|
||||||
console.log("Valid", valid);
|
console.log("Valid", valid);
|
||||||
if (!valid) return;
|
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 [isGenerating, setIsGenerating] = createSignal(false);
|
||||||
|
|
||||||
const hwReportQuery = createQuery(() => ({
|
const hwReportQuery = useQuery(() => ({
|
||||||
queryKey: [props.dir, props.machine_id, "hw_report"],
|
queryKey: [props.dir, props.machine_id, "hw_report"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const result = await callApi("show_machine_hardware_config", {
|
const result = await callApi("show_machine_hardware_config", {
|
||||||
@@ -78,15 +100,9 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
|||||||
const { activeClanURI } = useClanContext();
|
const { activeClanURI } = useClanContext();
|
||||||
|
|
||||||
const generateReport = async (e: Event) => {
|
const generateReport = async (e: Event) => {
|
||||||
const curr_uri = activeClanURI();
|
const currentRemoteData = remoteData();
|
||||||
if (!curr_uri) return;
|
if (!currentRemoteData.address) {
|
||||||
|
console.error("Target address is not set");
|
||||||
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");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +137,8 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
|||||||
opts: {
|
opts: {
|
||||||
machine: {
|
machine: {
|
||||||
name: props.machine_id,
|
name: props.machine_id,
|
||||||
private_key: sshFile?.name,
|
|
||||||
flake: {
|
flake: {
|
||||||
identifier: curr_uri,
|
identifier: active_clan,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
backend: "nixos-facter",
|
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="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
||||||
<div class="flex h-full flex-col gap-6 p-4">
|
<div class="flex h-full flex-col gap-6 p-4">
|
||||||
<Group>
|
<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) => (
|
{(field, fieldProps) => (
|
||||||
<TextInput
|
<input
|
||||||
error={field.error}
|
{...fieldProps}
|
||||||
variant="ghost"
|
type="hidden"
|
||||||
label="Target ip"
|
value={remoteData().address}
|
||||||
value={field.value || ""}
|
|
||||||
inputProps={fieldProps}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</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>
|
||||||
<Group>
|
<Group>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Typography } from "@/src/components/Typography";
|
|||||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||||
import { InputLabel } from "@/src/components/inputBase";
|
import { InputLabel } from "@/src/components/inputBase";
|
||||||
import { Group, Section, SectionHeader } from "@/src/components/group";
|
import { Group, Section, SectionHeader } from "@/src/components/group";
|
||||||
import { AllStepsValues } from "../details";
|
import { AllStepsValues } from "../types";
|
||||||
import { Badge } from "@/src/components/badge";
|
import { Badge } from "@/src/components/badge";
|
||||||
import Icon from "@/src/components/icon";
|
import Icon from "@/src/components/icon";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useNavigate } from "@solidjs/router";
|
|||||||
import { useQueryClient } from "@tanstack/solid-query";
|
import { useQueryClient } from "@tanstack/solid-query";
|
||||||
import { Match, Switch } from "solid-js";
|
import { Match, Switch } from "solid-js";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
import { MachineAvatar } from "./avatar";
|
import { MachineAvatar } from "./components";
|
||||||
import { DynForm } from "@/src/Form/form";
|
import { DynForm } from "@/src/Form/form";
|
||||||
import Fieldset from "@/src/Form/fieldset";
|
import Fieldset from "@/src/Form/fieldset";
|
||||||
import Accordion from "@/src/components/accordion";
|
import Accordion from "@/src/components/accordion";
|
||||||
44
pkgs/clan-app/ui/src/routes/machines/machine-details.tsx
Normal file
44
pkgs/clan-app/ui/src/routes/machines/machine-details.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
pkgs/clan-app/ui/src/routes/machines/machine-install.tsx
Normal file
44
pkgs/clan-app/ui/src/routes/machines/machine-install.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
pkgs/clan-app/ui/src/routes/machines/types.ts
Normal file
12
pkgs/clan-app/ui/src/routes/machines/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user