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`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="z-[1000] shadow"
|
||||
class="rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
|
||||
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll p-1">
|
||||
<Show when={!props.loading} fallback={"Loading ...."}>
|
||||
<For each={props.options}>
|
||||
{(opt) => (
|
||||
|
||||
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,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div class="bg-def-3 p-10">
|
||||
<div class="p-10 bg-def-3">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateMachine,
|
||||
MachineDetails,
|
||||
MachineListView,
|
||||
MachineInstall,
|
||||
} from "./routes/machines";
|
||||
import { Layout } from "./layout/layout";
|
||||
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
|
||||
@@ -81,6 +82,12 @@ export const routes: AppRoute[] = [
|
||||
hidden: true,
|
||||
component: () => <VarsPage />,
|
||||
},
|
||||
{
|
||||
path: "/:id/install",
|
||||
label: "Install",
|
||||
hidden: true,
|
||||
component: () => <MachineInstall />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
required,
|
||||
reset,
|
||||
SubmitHandler,
|
||||
ResponseData,
|
||||
} from "@modular-forms/solid";
|
||||
import toast from "solid-toast";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
@@ -18,7 +19,7 @@ type CreateForm = Meta & {
|
||||
};
|
||||
|
||||
export const CreateClan = () => {
|
||||
const [formStore, { Form, Field }] = createForm<CreateForm>({
|
||||
const [formStore, { Form, Field }] = createForm<CreateForm, ResponseData>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
|
||||
@@ -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 "./create";
|
||||
export * from "./list";
|
||||
export * from "./machine-details";
|
||||
export * from "./machine-create";
|
||||
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 { Typography } from "@/src/components/Typography";
|
||||
import { Group } from "@/src/components/group";
|
||||
import { useContext } from "corvu/dialog";
|
||||
|
||||
export interface DiskValues extends FieldValues {
|
||||
placeholders: {
|
||||
@@ -49,8 +48,6 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const modalContext = useContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
@@ -116,7 +113,6 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
|
||||
placeholder="Select a disk"
|
||||
selectProps={fieldProps}
|
||||
required={!props.initial?.initialized}
|
||||
portalRef={modalContext.contentRef}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -14,20 +14,21 @@ import {
|
||||
validate,
|
||||
} from "@modular-forms/solid";
|
||||
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
|
||||
import { TextInput } from "@/src/Form/fields";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { Badge } from "@/src/components/badge";
|
||||
import { Group } from "@/src/components/group";
|
||||
import {
|
||||
type FileDialogOptions,
|
||||
FileSelectorField,
|
||||
} from "@/src/components/fileSelect";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
import {
|
||||
RemoteForm,
|
||||
type RemoteData,
|
||||
HostKeyCheck,
|
||||
} from "@/src/components/RemoteForm";
|
||||
import { ac } from "vitest/dist/chunks/reporters.d.C-cu31ET";
|
||||
|
||||
export type HardwareValues = FieldValues & {
|
||||
report: boolean;
|
||||
target: string;
|
||||
sshKey?: File;
|
||||
remoteData: RemoteData;
|
||||
};
|
||||
|
||||
export interface StepProps<T> {
|
||||
@@ -42,17 +43,38 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
||||
initialValues: (props.initial as HardwareValues) || {},
|
||||
});
|
||||
|
||||
// Initialize remote data from existing target or create new default
|
||||
const [remoteData, setRemoteData] = createSignal<RemoteData>({
|
||||
address: props.initial?.target || "",
|
||||
user: "root",
|
||||
command_prefix: "sudo",
|
||||
port: 22,
|
||||
forward_agent: false,
|
||||
host_key_check: 1, // 0 = ASK
|
||||
verbose_ssh: false,
|
||||
ssh_options: {},
|
||||
tor_socks: false,
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<HardwareValues> = async (values, event) => {
|
||||
console.log("Submit Hardware", { values });
|
||||
const valid = await validate(formStore);
|
||||
console.log("Valid", valid);
|
||||
if (!valid) return;
|
||||
props.handleNext(values);
|
||||
|
||||
// Include remote data in the values
|
||||
const submitValues = {
|
||||
...values,
|
||||
remoteData: remoteData(),
|
||||
target: remoteData().address, // Keep target for backward compatibility
|
||||
};
|
||||
|
||||
props.handleNext(submitValues);
|
||||
};
|
||||
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
|
||||
const hwReportQuery = createQuery(() => ({
|
||||
const hwReportQuery = useQuery(() => ({
|
||||
queryKey: [props.dir, props.machine_id, "hw_report"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("show_machine_hardware_config", {
|
||||
@@ -78,15 +100,9 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
||||
const { activeClanURI } = useClanContext();
|
||||
|
||||
const generateReport = async (e: Event) => {
|
||||
const curr_uri = activeClanURI();
|
||||
if (!curr_uri) return;
|
||||
|
||||
await validate(formStore, "target");
|
||||
const target = getValue(formStore, "target");
|
||||
const sshFile = getValue(formStore, "sshKey") as File | undefined;
|
||||
|
||||
if (!target) {
|
||||
console.error("Target is not set");
|
||||
const currentRemoteData = remoteData();
|
||||
if (!currentRemoteData.address) {
|
||||
console.error("Target address is not set");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,9 +137,8 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
||||
opts: {
|
||||
machine: {
|
||||
name: props.machine_id,
|
||||
private_key: sshFile?.name,
|
||||
flake: {
|
||||
identifier: curr_uri,
|
||||
identifier: active_clan,
|
||||
},
|
||||
},
|
||||
backend: "nixos-facter",
|
||||
@@ -142,35 +157,26 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
||||
<div class="flex h-full flex-col gap-6 p-4">
|
||||
<Group>
|
||||
<Field name="target" validate={required("Target must be provided")}>
|
||||
<RemoteForm
|
||||
showSave={false}
|
||||
machine={{
|
||||
name: props.machine_id,
|
||||
flake: {
|
||||
identifier: props.dir,
|
||||
},
|
||||
}}
|
||||
field="targetHost"
|
||||
/>
|
||||
{/* Hidden field for form validation */}
|
||||
<Field name="target">
|
||||
{(field, fieldProps) => (
|
||||
<TextInput
|
||||
error={field.error}
|
||||
variant="ghost"
|
||||
label="Target ip"
|
||||
value={field.value || ""}
|
||||
inputProps={fieldProps}
|
||||
required
|
||||
<input
|
||||
{...fieldProps}
|
||||
type="hidden"
|
||||
value={remoteData().address}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<FileSelectorField
|
||||
Field={Field}
|
||||
of={File}
|
||||
multiple={false}
|
||||
name="sshKey" // Corresponds to FlashFormValues.sshKeys
|
||||
label="SSH Private Key"
|
||||
description="Provide your SSH private key for secure, passwordless connections."
|
||||
fileDialogOptions={
|
||||
{
|
||||
title: "Select SSH Keys",
|
||||
initial_folder: "~/.ssh",
|
||||
} as FileDialogOptions
|
||||
}
|
||||
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
|
||||
// e.g. validate={[required("At least one SSH key is required.")]}
|
||||
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Field
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Typography } from "@/src/components/Typography";
|
||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||
import { InputLabel } from "@/src/components/inputBase";
|
||||
import { Group, Section, SectionHeader } from "@/src/components/group";
|
||||
import { AllStepsValues } from "../details";
|
||||
import { AllStepsValues } from "../types";
|
||||
import { Badge } from "@/src/components/badge";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useNavigate } from "@solidjs/router";
|
||||
import { useQueryClient } from "@tanstack/solid-query";
|
||||
import { Match, Switch } from "solid-js";
|
||||
import toast from "solid-toast";
|
||||
import { MachineAvatar } from "./avatar";
|
||||
import { MachineAvatar } from "./components";
|
||||
import { DynForm } from "@/src/Form/form";
|
||||
import Fieldset from "@/src/Form/fieldset";
|
||||
import Accordion from "@/src/components/accordion";
|
||||
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