Compare commits
35 Commits
pr-5024
...
fix-module
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9573636d8 | ||
|
|
3862ad2a06 | ||
|
|
c447aec9d3 | ||
|
|
5137d19b0f | ||
|
|
453f2649d3 | ||
|
|
58cfcf3d25 | ||
|
|
c260a97cc1 | ||
|
|
3eb64870b0 | ||
|
|
7412b958c6 | ||
|
|
a0c27194a6 | ||
|
|
3437af29cb | ||
|
|
0b1c12d2e5 | ||
|
|
8620761bbd | ||
|
|
d793b6ca07 | ||
|
|
17e9231657 | ||
|
|
acc2674d79 | ||
|
|
c34a21a3bb | ||
|
|
275bff23da | ||
|
|
1a766a3447 | ||
|
|
c22844c83b | ||
|
|
5472ca0e21 | ||
|
|
ad890b0b6b | ||
|
|
a364b5ebf3 | ||
|
|
d0134d131e | ||
|
|
ccf0dace11 | ||
|
|
9977a903ce | ||
|
|
dc9bf5068e | ||
|
|
6b4f79c9fa | ||
|
|
b2985b59e9 | ||
|
|
d4ac3b83ee | ||
|
|
00bf55be5a | ||
|
|
851d6aaa89 | ||
|
|
f007279bee | ||
|
|
5a3381d9ff | ||
|
|
54a8ec717e |
6
devFlake/flake.lock
generated
6
devFlake/flake.lock
generated
@@ -84,11 +84,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-dev": {
|
"nixpkgs-dev": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1756400612,
|
"lastModified": 1756578978,
|
||||||
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
|
"narHash": "sha256-dLgwMLIMyHlSeIDsoT2OcZBkuruIbjhIAv1sGANwtes=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
|
"rev": "a85a50bef870537a9705f64ed75e54d1f4bf9c23",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ in
|
|||||||
in
|
in
|
||||||
{ config, ... }:
|
{ config, ... }:
|
||||||
{
|
{
|
||||||
|
staticModules = clan-core.clan.modules;
|
||||||
|
|
||||||
distributedServices = clanLib.inventory.mapInstances {
|
distributedServices = clanLib.inventory.mapInstances {
|
||||||
inherit (clanConfig) inventory exportsModule;
|
inherit (clanConfig) inventory exportsModule;
|
||||||
inherit flakeInputs directory;
|
inherit flakeInputs directory;
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ let
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
options.staticModules = lib.mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
type = lib.types.raw;
|
||||||
|
|
||||||
|
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
|
||||||
|
};
|
||||||
options.modulesPerSource = lib.mkOption {
|
options.modulesPerSource = lib.mkOption {
|
||||||
# { sourceName :: { moduleName :: {} }}
|
# { sourceName :: { moduleName :: {} }}
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||||
|
|
||||||
# Enable automatic state-version generation.
|
# Enable automatic state-version generation.
|
||||||
clan.core.settings.state-version.enable = true;
|
clan.core.settings.state-version.enable = lib.mkDefault true;
|
||||||
|
|
||||||
# Use systemd during boot as well except:
|
# Use systemd during boot as well except:
|
||||||
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ let
|
|||||||
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
||||||
&& (builtins.tryEval kernelPackages).success
|
&& (builtins.tryEval kernelPackages).success
|
||||||
&& (
|
&& (
|
||||||
(!isUnstable && !kernelPackages.zfs.meta.broken)
|
let
|
||||||
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
|
zfsPackage =
|
||||||
|
if isUnstable then
|
||||||
|
kernelPackages.zfs_unstable
|
||||||
|
else
|
||||||
|
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
|
||||||
|
in
|
||||||
|
!(zfsPackage.meta.broken or false)
|
||||||
)
|
)
|
||||||
) pkgs.linuxKernel.packages;
|
) pkgs.linuxKernel.packages;
|
||||||
latestKernelPackage = lib.last (
|
latestKernelPackage = lib.last (
|
||||||
@@ -24,5 +30,5 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Note this might jump back and worth as kernel get added or removed.
|
# Note this might jump back and worth as kernel get added or removed.
|
||||||
boot.kernelPackages = latestKernelPackage;
|
boot.kernelPackages = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { A } from "@solidjs/router";
|
|||||||
import { Accordion } from "@kobalte/core/accordion";
|
import { Accordion } from "@kobalte/core/accordion";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import { For, useContext } from "solid-js";
|
import { For, Show, useContext } from "solid-js";
|
||||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import { SidebarProps } from "./Sidebar";
|
import { SidebarProps } from "./Sidebar";
|
||||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
import { ClanContext } from "@/src/routes/Clan/Clan";
|
||||||
|
import { Button } from "../Button/Button";
|
||||||
|
|
||||||
interface MachineProps {
|
interface MachineProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -71,6 +72,15 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
// we want them all to be open by default
|
// we want them all to be open by default
|
||||||
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
const defaultAccordionValues = ["your-machines", ...sectionLabels];
|
||||||
|
|
||||||
|
const machines = () => {
|
||||||
|
if (!ctx.machinesQuery.isSuccess) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = ctx.machinesQuery.data;
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="sidebar-body">
|
<div class="sidebar-body">
|
||||||
<Accordion
|
<Accordion
|
||||||
@@ -100,18 +110,42 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</Accordion.Header>
|
</Accordion.Header>
|
||||||
<Accordion.Content class="content">
|
<Accordion.Content class="content">
|
||||||
<nav>
|
<Show
|
||||||
<For each={Object.entries(ctx.machinesQuery.data || {})}>
|
when={machines()}
|
||||||
{([id, machine]) => (
|
fallback={
|
||||||
<MachineRoute
|
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
||||||
clanURI={clanURI}
|
<Typography
|
||||||
machineID={id}
|
hierarchy="body"
|
||||||
name={machine.name || id}
|
size="s"
|
||||||
serviceCount={0}
|
weight="medium"
|
||||||
/>
|
inverted
|
||||||
)}
|
>
|
||||||
</For>
|
No machines yet
|
||||||
</nav>
|
</Typography>
|
||||||
|
<Button
|
||||||
|
hierarchy="primary"
|
||||||
|
size="s"
|
||||||
|
startIcon="Machine"
|
||||||
|
onClick={() => ctx.setShowAddMachine(true)}
|
||||||
|
>
|
||||||
|
Add machine
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<nav>
|
||||||
|
<For each={Object.entries(machines()!)}>
|
||||||
|
{([id, machine]) => (
|
||||||
|
<MachineRoute
|
||||||
|
clanURI={clanURI}
|
||||||
|
machineID={id}
|
||||||
|
name={machine.name || id}
|
||||||
|
serviceCount={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</Show>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface SidebarPaneProps {
|
|||||||
class?: string;
|
class?: string;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
subHeader?: () => JSX.Element;
|
subHeader?: JSX.Element;
|
||||||
children: JSX.Element;
|
children: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
|||||||
</KButton>
|
</KButton>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.subHeader}>
|
<Show when={props.subHeader}>
|
||||||
<div class="sub-header">{props.subHeader!()}</div>
|
<div class="sub-header">{props.subHeader}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="body">{props.children}</div>
|
<div class="body">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal, Show } from "solid-js";
|
import { createSignal, Show } from "solid-js";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { InstallModal } from "@/src/workflows/Install/install";
|
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine";
|
||||||
import { useMachineName } from "@/src/hooks/clan";
|
import { useMachineName } from "@/src/hooks/clan";
|
||||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||||
import styles from "./SidebarSectionInstall.module.css";
|
import styles from "./SidebarSectionInstall.module.css";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
|
Signal,
|
||||||
useContext,
|
useContext,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import {
|
import {
|
||||||
@@ -24,16 +25,11 @@ import {
|
|||||||
useClanListQuery,
|
useClanListQuery,
|
||||||
useMachinesQuery,
|
useMachinesQuery,
|
||||||
} from "@/src/hooks/queries";
|
} from "@/src/hooks/queries";
|
||||||
import { callApi } from "@/src/hooks/api";
|
|
||||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
|
||||||
import { Splash } from "@/src/scene/splash";
|
import { Splash } from "@/src/scene/splash";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import styles from "./Clan.module.css";
|
import styles from "./Clan.module.css";
|
||||||
import { Modal } from "@/src/components/Modal/Modal";
|
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
|
||||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
|
||||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||||
import { UseQueryResult } from "@tanstack/solid-query";
|
import { UseQueryResult } from "@tanstack/solid-query";
|
||||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||||
@@ -43,6 +39,7 @@ import {
|
|||||||
} from "@/src/workflows/Service/Service";
|
} from "@/src/workflows/Service/Service";
|
||||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
|
||||||
interface ClanContextProps {
|
interface ClanContextProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -53,6 +50,9 @@ interface ClanContextProps {
|
|||||||
|
|
||||||
isLoading(): boolean;
|
isLoading(): boolean;
|
||||||
isError(): boolean;
|
isError(): boolean;
|
||||||
|
|
||||||
|
showAddMachine(): boolean;
|
||||||
|
setShowAddMachine(value: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DefaultClanContext implements ClanContextProps {
|
class DefaultClanContext implements ClanContextProps {
|
||||||
@@ -66,6 +66,8 @@ class DefaultClanContext implements ClanContextProps {
|
|||||||
|
|
||||||
allQueries: UseQueryResult[];
|
allQueries: UseQueryResult[];
|
||||||
|
|
||||||
|
showAddMachineSignal: Signal<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
clanURI: string,
|
clanURI: string,
|
||||||
machinesQuery: MachinesQueryResult,
|
machinesQuery: MachinesQueryResult,
|
||||||
@@ -80,6 +82,8 @@ class DefaultClanContext implements ClanContextProps {
|
|||||||
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
|
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||||
|
|
||||||
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
|
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
|
||||||
|
|
||||||
|
this.showAddMachineSignal = createSignal(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading(): boolean {
|
isLoading(): boolean {
|
||||||
@@ -89,6 +93,16 @@ class DefaultClanContext implements ClanContextProps {
|
|||||||
isError(): boolean {
|
isError(): boolean {
|
||||||
return this.activeClanQuery.isError;
|
return this.activeClanQuery.isError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowAddMachine(value: boolean) {
|
||||||
|
const [_, setShow] = this.showAddMachineSignal;
|
||||||
|
setShow(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddMachine(): boolean {
|
||||||
|
const [show, _] = this.showAddMachineSignal;
|
||||||
|
return show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClanContext = createContext<ClanContextProps>();
|
export const ClanContext = createContext<ClanContextProps>();
|
||||||
@@ -134,56 +148,6 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CreateFormValues extends FieldValues {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (formValues: CreateFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MockCreateMachine = (props: MockProps) => {
|
|
||||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={true}
|
|
||||||
onClose={() => {
|
|
||||||
reset(form);
|
|
||||||
props.onClose();
|
|
||||||
}}
|
|
||||||
class={cx(styles.createModal)}
|
|
||||||
title="Create Machine"
|
|
||||||
>
|
|
||||||
<Form class="flex flex-col" onSubmit={props.onSubmit}>
|
|
||||||
<Field name="name">
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
{...field}
|
|
||||||
label="Name"
|
|
||||||
size="s"
|
|
||||||
required={true}
|
|
||||||
input={{ ...props, placeholder: "name", autofocus: true }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="mt-4 flex w-full items-center justify-end gap-4">
|
|
||||||
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClanSceneController = (props: RouteSectionProps) => {
|
const ClanSceneController = (props: RouteSectionProps) => {
|
||||||
const ctx = useContext(ClanContext);
|
const ctx = useContext(ClanContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
@@ -194,7 +158,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
|
|
||||||
const [showService, setShowService] = createSignal(false);
|
const [showService, setShowService] = createSignal(false);
|
||||||
|
|
||||||
const [showModal, setShowModal] = createSignal(false);
|
|
||||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||||
resolve: ({ id }: { id: string }) => void;
|
resolve: ({ id }: { id: string }) => void;
|
||||||
reject: (err: unknown) => void;
|
reject: (err: unknown) => void;
|
||||||
@@ -202,45 +165,11 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
|
|
||||||
const onCreate = async (): Promise<{ id: string }> => {
|
const onCreate = async (): Promise<{ id: string }> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setShowModal(true);
|
ctx.setShowAddMachine(true);
|
||||||
setCurrentPromise({ resolve, reject });
|
setCurrentPromise({ resolve, reject });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddService = async (): Promise<{ id: string }> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
setShowService((v) => !v);
|
|
||||||
console.log("setting current promise");
|
|
||||||
setCurrentPromise({ resolve, reject });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendCreate = async (values: CreateFormValues) => {
|
|
||||||
const api = callApi("create_machine", {
|
|
||||||
opts: {
|
|
||||||
clan_dir: {
|
|
||||||
identifier: ctx.clanURI,
|
|
||||||
},
|
|
||||||
machine: {
|
|
||||||
name: values.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const res = await api.result;
|
|
||||||
if (res.status === "error") {
|
|
||||||
// TODO: Handle displaying errors
|
|
||||||
console.error("Error creating machine:");
|
|
||||||
|
|
||||||
// Important: rejects the promise
|
|
||||||
throw new Error(res.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// trigger a refetch of the machines query
|
|
||||||
ctx.machinesQuery.refetch();
|
|
||||||
|
|
||||||
return { id: values.name };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [loadingError, setLoadingError] = createSignal<
|
const [loadingError, setLoadingError] = createSignal<
|
||||||
{ title: string; description: string } | undefined
|
{ title: string; description: string } | undefined
|
||||||
>();
|
>();
|
||||||
@@ -312,9 +241,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
console.error("Error creating service instance", result.errors);
|
console.error("Error creating service instance", result.errors);
|
||||||
}
|
}
|
||||||
toast.success("Created");
|
toast.success("Created");
|
||||||
//
|
|
||||||
currentPromise()?.resolve({ id: "0" });
|
|
||||||
setShowService(false);
|
setShowService(false);
|
||||||
|
setWorldMode("select");
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -322,7 +250,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
if (mode === "service") {
|
if (mode === "service") {
|
||||||
setShowService(true);
|
setShowService(true);
|
||||||
} else {
|
} else {
|
||||||
// todo: request close instead of force close
|
// TODO: request soft close instead of forced close
|
||||||
setShowService(false);
|
setShowService(false);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -333,22 +261,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
<Show when={loadingError()}>
|
<Show when={loadingError()}>
|
||||||
<ListClansModal error={loadingError()} />
|
<ListClansModal error={loadingError()} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showModal()}>
|
<Show when={ctx.showAddMachine()}>
|
||||||
<MockCreateMachine
|
<AddMachine
|
||||||
onClose={() => {
|
onCreated={async (id) => {
|
||||||
setShowModal(false);
|
const promise = currentPromise();
|
||||||
currentPromise()?.reject(new Error("User cancelled"));
|
if (promise) {
|
||||||
}}
|
await ctx.machinesQuery.refetch();
|
||||||
onSubmit={async (values) => {
|
promise.resolve({ id });
|
||||||
try {
|
setCurrentPromise(null);
|
||||||
const result = await sendCreate(values);
|
|
||||||
currentPromise()?.resolve(result);
|
|
||||||
setShowModal(false);
|
|
||||||
} catch (err) {
|
|
||||||
currentPromise()?.reject(err);
|
|
||||||
setShowModal(false);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
ctx.setShowAddMachine(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
@@ -370,7 +295,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
handleSubmit={handleSubmitService}
|
handleSubmit={handleSubmitService}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowService(false);
|
setShowService(false);
|
||||||
setWorldMode("default");
|
setWorldMode("select");
|
||||||
currentPromise()?.resolve({ id: "0" });
|
currentPromise()?.resolve({ id: "0" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
navigateToClan(navigate, clanURI);
|
navigateToClan(navigate, clanURI);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sidebarPane = (machineName: string) => {
|
const sections = () => {
|
||||||
|
const machineName = useMachineName();
|
||||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||||
|
|
||||||
// we have to update the whole machine model rather than just the sub fields that were changed
|
// we have to update the whole machine model rather than just the sub fields that were changed
|
||||||
@@ -51,25 +52,35 @@ export const Machine = (props: RouteSectionProps) => {
|
|||||||
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.sidebarPaneContainer}>
|
<>
|
||||||
<SidebarPane
|
<SidebarSectionInstall
|
||||||
title={machineName}
|
clanURI={clanURI}
|
||||||
onClose={onClose}
|
machineName={useMachineName()}
|
||||||
subHeader={() => (
|
/>
|
||||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
<SectionGeneral {...sectionProps} />
|
||||||
)}
|
<SectionTags {...sectionProps} />
|
||||||
>
|
</>
|
||||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
|
||||||
<SectionGeneral {...sectionProps} />
|
|
||||||
<SectionTags {...sectionProps} />
|
|
||||||
</SidebarPane>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={useMachineName()} keyed>
|
<Show when={useMachineName()}>
|
||||||
{sidebarPane(useMachineName())}
|
<div class={styles.sidebarPaneContainer}>
|
||||||
|
<SidebarPane
|
||||||
|
title={useMachineName()}
|
||||||
|
onClose={onClose}
|
||||||
|
subHeader={
|
||||||
|
<Show when={useMachineName()} keyed>
|
||||||
|
<SidebarMachineStatus
|
||||||
|
clanURI={clanURI}
|
||||||
|
machineName={useMachineName()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sections()}
|
||||||
|
</SidebarPane>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export class MachineManager {
|
|||||||
|
|
||||||
const actualIds = Object.keys(machinesQueryResult.data);
|
const actualIds = Object.keys(machinesQueryResult.data);
|
||||||
const machinePositions = machinePositionsSignal();
|
const machinePositions = machinePositionsSignal();
|
||||||
|
|
||||||
// Remove stale
|
// Remove stale
|
||||||
for (const id of Object.keys(machinePositions)) {
|
for (const id of Object.keys(machinePositions)) {
|
||||||
if (!actualIds.includes(id)) {
|
if (!actualIds.includes(id)) {
|
||||||
|
console.log("Removing stale machine", id);
|
||||||
setMachinePos(id, null);
|
setMachinePos(id, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +61,11 @@ export class MachineManager {
|
|||||||
//
|
//
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const positions = machinePositionsSignal();
|
const positions = machinePositionsSignal();
|
||||||
|
if (!positions) return;
|
||||||
|
|
||||||
// Remove machines from scene
|
// Remove machines from scene
|
||||||
for (const [id, repr] of this.machines) {
|
for (const [id, repr] of this.machines) {
|
||||||
if (!(id in positions)) {
|
if (!Object.keys(positions).includes(id)) {
|
||||||
repr.dispose(scene);
|
repr.dispose(scene);
|
||||||
this.machines.delete(id);
|
this.machines.delete(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.cubes-scene-container {
|
.cubes-scene-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Accessor } from "solid-js";
|
|||||||
import { renderLoop } from "./RenderLoop";
|
import { renderLoop } from "./RenderLoop";
|
||||||
import { ObjectRegistry } from "./ObjectRegistry";
|
import { ObjectRegistry } from "./ObjectRegistry";
|
||||||
import { MachineManager } from "./MachineManager";
|
import { MachineManager } from "./MachineManager";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
function garbageCollectGroup(group: THREE.Group) {
|
function garbageCollectGroup(group: THREE.Group) {
|
||||||
for (const child of group.children) {
|
for (const child of group.children) {
|
||||||
@@ -64,7 +65,7 @@ export function useMachineClick() {
|
|||||||
/*Gloabl signal*/
|
/*Gloabl signal*/
|
||||||
const [worldMode, setWorldMode] = createSignal<
|
const [worldMode, setWorldMode] = createSignal<
|
||||||
"default" | "select" | "service" | "create"
|
"default" | "select" | "service" | "create"
|
||||||
>("default");
|
>("select");
|
||||||
export { worldMode, setWorldMode };
|
export { worldMode, setWorldMode };
|
||||||
|
|
||||||
export function CubeScene(props: {
|
export function CubeScene(props: {
|
||||||
@@ -101,6 +102,8 @@ export function CubeScene(props: {
|
|||||||
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
||||||
"grid",
|
"grid",
|
||||||
);
|
);
|
||||||
|
// Managed by controls
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
|
||||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||||
|
|
||||||
@@ -273,6 +276,13 @@ export function CubeScene(props: {
|
|||||||
bgCamera,
|
bgCamera,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
controls.addEventListener("start", (e) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
});
|
||||||
|
controls.addEventListener("end", (e) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
});
|
||||||
|
|
||||||
// Lighting
|
// Lighting
|
||||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||||
scene.add(ambientLight);
|
scene.add(ambientLight);
|
||||||
@@ -582,7 +592,17 @@ export function CubeScene(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="cubes-scene-container" ref={(el) => (container = el)} />
|
<div
|
||||||
|
class={cx(
|
||||||
|
"cubes-scene-container",
|
||||||
|
worldMode() === "default" && "cursor-no-drop",
|
||||||
|
worldMode() === "select" && "cursor-pointer",
|
||||||
|
worldMode() === "service" && "cursor-pointer",
|
||||||
|
worldMode() === "create" && "cursor-cell",
|
||||||
|
isDragging() && "!cursor-grabbing",
|
||||||
|
)}
|
||||||
|
ref={(el) => (container = el)}
|
||||||
|
/>
|
||||||
<div class="toolbar-container">
|
<div class="toolbar-container">
|
||||||
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||||
{props.toolbarPopup}
|
{props.toolbarPopup}
|
||||||
@@ -592,9 +612,7 @@ export function CubeScene(props: {
|
|||||||
description="Select machine"
|
description="Select machine"
|
||||||
name="Select"
|
name="Select"
|
||||||
icon="Cursor"
|
icon="Cursor"
|
||||||
onClick={() =>
|
onClick={() => setWorldMode("select")}
|
||||||
setWorldMode((v) => (v === "select" ? "default" : "select"))
|
|
||||||
}
|
|
||||||
selected={worldMode() === "select"}
|
selected={worldMode() === "select"}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@@ -611,11 +629,11 @@ export function CubeScene(props: {
|
|||||||
icon="Services"
|
icon="Services"
|
||||||
selected={worldMode() === "service"}
|
selected={worldMode() === "service"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWorldMode((v) => (v === "service" ? "default" : "service"));
|
setWorldMode("service");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon="Reload"
|
icon="Update"
|
||||||
name="Reload"
|
name="Reload"
|
||||||
description="Reload machines"
|
description="Reload machines"
|
||||||
onClick={() => machinesQuery.refetch()}
|
onClick={() => machinesQuery.refetch()}
|
||||||
|
|||||||
119
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
119
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
MemoryRouter,
|
||||||
|
RouteDefinition,
|
||||||
|
} from "@solidjs/router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||||
|
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
|
||||||
|
import {
|
||||||
|
ApiCall,
|
||||||
|
OperationNames,
|
||||||
|
OperationResponse,
|
||||||
|
SuccessQuery,
|
||||||
|
} from "@/src/hooks/api";
|
||||||
|
|
||||||
|
type ResultDataMap = {
|
||||||
|
[K in OperationNames]: SuccessQuery<K>["data"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||||
|
name: K,
|
||||||
|
_args: unknown,
|
||||||
|
): ApiCall<K> => {
|
||||||
|
// TODO: Make this configurable for every story
|
||||||
|
const resultData: Partial<ResultDataMap> = {
|
||||||
|
list_machines: {
|
||||||
|
pandora: {
|
||||||
|
name: "pandora",
|
||||||
|
},
|
||||||
|
enceladus: {
|
||||||
|
name: "enceladus",
|
||||||
|
},
|
||||||
|
dione: {
|
||||||
|
name: "dione",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: "mock",
|
||||||
|
cancel: () => Promise.resolve(),
|
||||||
|
result: new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
op_key: "1",
|
||||||
|
status: "success",
|
||||||
|
data: resultData[name],
|
||||||
|
} as OperationResponse<K>);
|
||||||
|
}, 1500);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof AddMachine> = {
|
||||||
|
title: "workflows/add-machine",
|
||||||
|
component: AddMachine,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext) => {
|
||||||
|
const Routes: RouteDefinition[] = [
|
||||||
|
{
|
||||||
|
path: "/clans/:clanURI",
|
||||||
|
component: () => (
|
||||||
|
<div class="w-[600px]">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const history = createMemoryHistory();
|
||||||
|
history.set({ value: "/clans/dGVzdA==", replace: true });
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiClientProvider client={{ fetch: mockFetcher }}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter
|
||||||
|
root={(props) => {
|
||||||
|
console.debug("Rendering MemoryRouter root with props:", props);
|
||||||
|
return props.children;
|
||||||
|
}}
|
||||||
|
history={history}
|
||||||
|
>
|
||||||
|
{Routes}
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiClientProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof AddMachine>;
|
||||||
|
|
||||||
|
export const General: Story = {
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Host: Story = {
|
||||||
|
args: {
|
||||||
|
initialStep: "host",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tags: Story = {
|
||||||
|
args: {
|
||||||
|
initialStep: "tags",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Progress: Story = {
|
||||||
|
args: {
|
||||||
|
initialStep: "progress",
|
||||||
|
},
|
||||||
|
};
|
||||||
136
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
136
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
createStepper,
|
||||||
|
defineSteps,
|
||||||
|
StepperProvider,
|
||||||
|
useStepper,
|
||||||
|
} from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
GeneralForm,
|
||||||
|
StepGeneral,
|
||||||
|
} from "@/src/workflows/AddMachine/StepGeneral";
|
||||||
|
import { Modal } from "@/src/components/Modal/Modal";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { StepHost } from "@/src/workflows/AddMachine/StepHost";
|
||||||
|
import { StepTags } from "@/src/workflows/AddMachine/StepTags";
|
||||||
|
import { StepProgress } from "./StepProgress";
|
||||||
|
|
||||||
|
interface AddMachineStepperProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddMachineStepper = (props: AddMachineStepperProps) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={stepSignal.currentStep().content}
|
||||||
|
onDone={props.onDone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AddMachineProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
initialStep?: AddMachineSteps[number]["id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMachineStoreType {
|
||||||
|
general: GeneralForm;
|
||||||
|
deploy: {
|
||||||
|
targetHost: string;
|
||||||
|
};
|
||||||
|
tags: {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = defineSteps([
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
title: "General",
|
||||||
|
content: StepGeneral,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "host",
|
||||||
|
title: "Host",
|
||||||
|
content: StepHost,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
title: "Tags",
|
||||||
|
content: StepTags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "progress",
|
||||||
|
title: "Creating...",
|
||||||
|
content: StepProgress,
|
||||||
|
isSplash: true,
|
||||||
|
},
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export type AddMachineSteps = typeof steps;
|
||||||
|
|
||||||
|
export const AddMachine = (props: AddMachineProps) => {
|
||||||
|
const stepper = createStepper(
|
||||||
|
{
|
||||||
|
steps,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialStep: props.initialStep || "general",
|
||||||
|
initialStoreData: { onCreated: props.onCreated },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const MetaHeader = () => {
|
||||||
|
const title = stepper.currentStep().title;
|
||||||
|
return (
|
||||||
|
<Show when={title}>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="default"
|
||||||
|
weight="medium"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = () => {
|
||||||
|
const defaultClass = "max-w-3xl h-fit";
|
||||||
|
|
||||||
|
const currentStep = stepper.currentStep();
|
||||||
|
if (!currentStep) {
|
||||||
|
return defaultClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentStep.id) {
|
||||||
|
default:
|
||||||
|
return defaultClass;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepperProvider stepper={stepper}>
|
||||||
|
<Modal
|
||||||
|
class={cx("w-screen", sizeClasses())}
|
||||||
|
title="Add Machine"
|
||||||
|
onClose={props.onClose}
|
||||||
|
open={true}
|
||||||
|
// @ts-expect-error some steps might not have
|
||||||
|
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
|
||||||
|
// @ts-expect-error some steps might not have
|
||||||
|
disablePadding={stepper.currentStep()?.isSplash}
|
||||||
|
>
|
||||||
|
<AddMachineStepper onDone={() => props.onClose()} />
|
||||||
|
</Modal>
|
||||||
|
</StepperProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
176
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
clearError,
|
||||||
|
createForm,
|
||||||
|
FieldValues,
|
||||||
|
getError,
|
||||||
|
getErrors,
|
||||||
|
setError,
|
||||||
|
SubmitHandler,
|
||||||
|
valiForm,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
|
import { Divider } from "@/src/components/Divider/Divider";
|
||||||
|
import { TextArea } from "@/src/components/Form/TextArea";
|
||||||
|
import { Select } from "@/src/components/Select/Select";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
|
import { useMachinesQuery } from "@/src/hooks/queries";
|
||||||
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
const PlatformOptions = [
|
||||||
|
{ label: "NixOS", value: "nixos" },
|
||||||
|
{ label: "Darwin", value: "darwin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GeneralSchema = v.object({
|
||||||
|
name: v.pipe(
|
||||||
|
v.string("Name must be a string"),
|
||||||
|
v.nonEmpty("Please enter a machine name"),
|
||||||
|
v.regex(
|
||||||
|
new RegExp(/^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/),
|
||||||
|
"Name must be a valid hostname e.g. alphanumeric characters and - only",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
description: v.optional(v.string("Description must be a string")),
|
||||||
|
machineClass: v.pipe(v.string(), v.nonEmpty()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface GeneralForm extends FieldValues {
|
||||||
|
machineClass: "nixos" | "darwin";
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepGeneral = () => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
const machines = useMachinesQuery(clanURI);
|
||||||
|
|
||||||
|
const machineNames = () => {
|
||||||
|
if (!machines.isSuccess) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(machines.data || {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<GeneralForm>({
|
||||||
|
validate: valiForm(GeneralSchema),
|
||||||
|
initialValues: { ...store.general, machineClass: "nixos" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<GeneralForm> = (values, event) => {
|
||||||
|
if (machineNames().includes(values.name)) {
|
||||||
|
setError(
|
||||||
|
formStore,
|
||||||
|
"name",
|
||||||
|
`A machine named '${values.name}' already exists. Please choose a different one.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(formStore, "name");
|
||||||
|
|
||||||
|
set("general", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formError = () => {
|
||||||
|
const errors = getErrors(formStore);
|
||||||
|
return errors.name || errors.description || errors.machineClass;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Show when={formError()}>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
icon="WarningFilled"
|
||||||
|
title="Error"
|
||||||
|
description={formError()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="name">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
orientation="horizontal"
|
||||||
|
input={{
|
||||||
|
...input,
|
||||||
|
placeholder: "A unique machine name.",
|
||||||
|
}}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "name") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Divider />
|
||||||
|
<Field name="description">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextArea
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Description"
|
||||||
|
orientation="horizontal"
|
||||||
|
input={{
|
||||||
|
...input,
|
||||||
|
placeholder: "A short description of the machine.",
|
||||||
|
}}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "description") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="machineClass">
|
||||||
|
{(field, props) => (
|
||||||
|
<Select
|
||||||
|
zIndex={100}
|
||||||
|
{...props}
|
||||||
|
value={field.value}
|
||||||
|
error={field.error}
|
||||||
|
required
|
||||||
|
label={{
|
||||||
|
label: "Platform",
|
||||||
|
}}
|
||||||
|
options={PlatformOptions}
|
||||||
|
name={field.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NextButton type="submit" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
76
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
createForm,
|
||||||
|
getError,
|
||||||
|
SubmitHandler,
|
||||||
|
valiForm,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
|
|
||||||
|
const HostSchema = v.object({
|
||||||
|
targetHost: v.pipe(v.string("Name must be a string")),
|
||||||
|
});
|
||||||
|
|
||||||
|
type HostForm = v.InferInput<typeof HostSchema>;
|
||||||
|
|
||||||
|
export const StepHost = () => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<HostForm>({
|
||||||
|
validate: valiForm(HostSchema),
|
||||||
|
initialValues: store.deploy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<HostForm> = (values, event) => {
|
||||||
|
set("deploy", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="targetHost">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Target"
|
||||||
|
orientation="horizontal"
|
||||||
|
input={{
|
||||||
|
...input,
|
||||||
|
placeholder: "root@flashinstaller.local",
|
||||||
|
}}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "targetHost") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<BackButton />
|
||||||
|
<NextButton type="submit" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
40
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Loader } from "@/src/components/Loader/Loader";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
|
|
||||||
|
export interface StepProgressProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepProgress = (props: StepProgressProps) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center gap-2.5 px-6 pb-7 pt-4">
|
||||||
|
<Show
|
||||||
|
when={store.error}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<Loader class="size-8" />
|
||||||
|
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
||||||
|
{store.general?.name} is being created
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
title="There was an error"
|
||||||
|
description={store.error}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
102
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { BackButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
|
||||||
|
import {
|
||||||
|
AddMachineSteps,
|
||||||
|
AddMachineStoreType,
|
||||||
|
} from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { MachineTags } from "@/src/components/Form/MachineTags";
|
||||||
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
const TagsSchema = v.object({
|
||||||
|
tags: v.array(v.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TagsForm = v.InferInput<typeof TagsSchema>;
|
||||||
|
|
||||||
|
export const StepTags = (props: { onDone: () => void }) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<TagsForm>({
|
||||||
|
validate: valiForm(TagsSchema),
|
||||||
|
initialValues: store.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<TagsForm> = async (values, event) => {
|
||||||
|
set("tags", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const call = apiClient.fetch("create_machine", {
|
||||||
|
opts: {
|
||||||
|
clan_dir: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
machine: {
|
||||||
|
...store.general,
|
||||||
|
...store.tags,
|
||||||
|
deploy: store.deploy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status == "error") {
|
||||||
|
// setError(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status == "success") {
|
||||||
|
console.log("Machine creation was successful");
|
||||||
|
if (store.general) {
|
||||||
|
store.onCreated(store.general.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("Done creating machine");
|
||||||
|
props.onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} class="h-full">
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="tags" type="string[]">
|
||||||
|
{(field, input) => (
|
||||||
|
<MachineTags
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
orientation="horizontal"
|
||||||
|
defaultValue={field.value}
|
||||||
|
defaultOptions={[]}
|
||||||
|
input={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<BackButton />
|
||||||
|
<Button hierarchy="primary" type="submit" endIcon="Flash">
|
||||||
|
Create Machine
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
import { InstallModal } from "./install";
|
import { InstallModal } from "./InstallMachine";
|
||||||
import {
|
import {
|
||||||
createMemoryHistory,
|
createMemoryHistory,
|
||||||
MemoryRouter,
|
MemoryRouter,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
||||||
import { InstallSteps } from "../install";
|
import { InstallSteps } from "../InstallMachine";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { StepLayout } from "../../Steps";
|
import { StepLayout } from "../../Steps";
|
||||||
import { NavSection } from "@/src/components/NavSection/NavSection";
|
import { NavSection } from "@/src/components/NavSection/NavSection";
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
valiForm,
|
valiForm,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { InstallSteps, InstallStoreType } from "../install";
|
import { InstallSteps, InstallStoreType } from "../InstallMachine";
|
||||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||||
import { Select } from "@/src/components/Select/Select";
|
import { Select } from "@/src/components/Select/Select";
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
|
import {
|
||||||
|
InstallSteps,
|
||||||
|
InstallStoreType,
|
||||||
|
PromptValues,
|
||||||
|
} from "../InstallMachine";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { Alert } from "@/src/components/Alert/Alert";
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
||||||
@@ -24,59 +24,42 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
): ApiCall<K> => {
|
): ApiCall<K> => {
|
||||||
// TODO: Make this configurable for every story
|
// TODO: Make this configurable for every story
|
||||||
const resultData: Partial<ResultDataMap> = {
|
const resultData: Partial<ResultDataMap> = {
|
||||||
list_service_modules: [
|
list_service_modules: {
|
||||||
{
|
core_input_name: "clan-core",
|
||||||
module: { name: "Borgbackup", input: "clan-core" },
|
modules: [
|
||||||
info: {
|
{
|
||||||
manifest: {
|
usage_ref: { name: "Borgbackup", input: null },
|
||||||
name: "Borgbackup",
|
instance_refs: [],
|
||||||
description: "This is module A",
|
native: true,
|
||||||
},
|
info: {
|
||||||
roles: {
|
manifest: {
|
||||||
client: null,
|
name: "Borgbackup",
|
||||||
server: null,
|
description: "This is module A",
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
client: null,
|
||||||
|
server: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
usage_ref: { name: "Zerotier", input: "fublub" },
|
||||||
module: { name: "Zerotier", input: "clan-core" },
|
instance_refs: [],
|
||||||
info: {
|
native: false,
|
||||||
manifest: {
|
info: {
|
||||||
name: "Zerotier",
|
manifest: {
|
||||||
description: "This is module B",
|
name: "Zerotier",
|
||||||
},
|
description: "This is module B",
|
||||||
roles: {
|
},
|
||||||
peer: null,
|
roles: {
|
||||||
moon: null,
|
peer: null,
|
||||||
controller: null,
|
moon: null,
|
||||||
|
controller: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
{
|
},
|
||||||
module: { name: "Admin", input: "clan-core" },
|
|
||||||
info: {
|
|
||||||
manifest: {
|
|
||||||
name: "Admin",
|
|
||||||
description: "This is module B",
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: { name: "Garage", input: "lo-l" },
|
|
||||||
info: {
|
|
||||||
manifest: {
|
|
||||||
name: "Garage",
|
|
||||||
description: "This is module B",
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
list_machines: {
|
list_machines: {
|
||||||
jon: {
|
jon: {
|
||||||
name: "jon",
|
name: "jon",
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ import {
|
|||||||
} from "@/src/scene/highlightStore";
|
} from "@/src/scene/highlightStore";
|
||||||
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
||||||
|
|
||||||
type ModuleItem = ServiceModules[number];
|
type ModuleItem = ServiceModules["modules"][number];
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
value: string;
|
value: string;
|
||||||
input?: string;
|
input?: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
raw: ModuleItem;
|
raw: ModuleItem;
|
||||||
@@ -68,20 +68,13 @@ const SelectService = () => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
||||||
setModuleOptions(
|
setModuleOptions(
|
||||||
serviceModulesQuery.data.map((m) => ({
|
serviceModulesQuery.data.modules.map((currService) => ({
|
||||||
value: `${m.module.name}:${m.module.input}`,
|
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
|
||||||
label: m.module.name,
|
label: currService.info.manifest.name,
|
||||||
description: m.info.manifest.description,
|
description: currService.info.manifest.description,
|
||||||
input: m.module.input,
|
input: currService.usage_ref.input,
|
||||||
raw: m,
|
raw: currService,
|
||||||
// TODO: include the instances that use this module
|
instances: currService.instance_refs,
|
||||||
instances: Object.entries(serviceInstancesQuery.data)
|
|
||||||
.filter(
|
|
||||||
([name, i]) =>
|
|
||||||
i.module?.name === m.module.name &&
|
|
||||||
(!i.module?.input || i.module?.input === m.module.input),
|
|
||||||
)
|
|
||||||
.map(([name, _]) => name),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,8 +89,8 @@ const SelectService = () => {
|
|||||||
if (!module) return;
|
if (!module) return;
|
||||||
|
|
||||||
set("module", {
|
set("module", {
|
||||||
name: module.raw.module.name,
|
name: module.raw.usage_ref.name,
|
||||||
input: module.raw.module.input,
|
input: module.raw.usage_ref.input,
|
||||||
raw: module.raw,
|
raw: module.raw,
|
||||||
});
|
});
|
||||||
// TODO: Ideally we need to ask
|
// TODO: Ideally we need to ask
|
||||||
@@ -183,11 +176,13 @@ const SelectService = () => {
|
|||||||
inverted
|
inverted
|
||||||
class="flex justify-between"
|
class="flex justify-between"
|
||||||
>
|
>
|
||||||
<span class="inline-block max-w-32 truncate align-middle">
|
<span class="inline-block max-w-48 truncate align-middle">
|
||||||
{item.description}
|
{item.description}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-block max-w-8 truncate align-middle">
|
<span class="inline-block max-w-12 truncate align-middle">
|
||||||
by {item.input}
|
<Show when={!item.raw.native} fallback="by clan-core">
|
||||||
|
by {item.input}
|
||||||
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,7 +533,7 @@ export interface InventoryInstance {
|
|||||||
name: string;
|
name: string;
|
||||||
module: {
|
module: {
|
||||||
name: string;
|
name: string;
|
||||||
input?: string;
|
input?: string | null;
|
||||||
};
|
};
|
||||||
roles: Record<string, RoleType>;
|
roles: Record<string, RoleType>;
|
||||||
}
|
}
|
||||||
@@ -551,7 +546,7 @@ interface RoleType {
|
|||||||
export interface ServiceStoreType {
|
export interface ServiceStoreType {
|
||||||
module: {
|
module: {
|
||||||
name: string;
|
name: string;
|
||||||
input: string;
|
input?: string | null;
|
||||||
raw?: ModuleItem;
|
raw?: ModuleItem;
|
||||||
};
|
};
|
||||||
roles: Record<string, TagType[]>;
|
roles: Record<string, TagType[]>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { JSX } from "solid-js";
|
import { JSX } from "solid-js";
|
||||||
import { useStepper } from "../hooks/stepper";
|
import { useStepper } from "../hooks/stepper";
|
||||||
import { Button, ButtonProps } from "../components/Button/Button";
|
import { Button, ButtonProps } from "../components/Button/Button";
|
||||||
import { InstallSteps } from "./Install/install";
|
import { InstallSteps } from "@/src/workflows/InstallMachine/InstallMachine";
|
||||||
import styles from "./Steps.module.css";
|
import styles from "./Steps.module.css";
|
||||||
|
|
||||||
interface StepLayoutProps {
|
interface StepLayoutProps {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FactStore(StoreBase):
|
|||||||
value: bytes,
|
value: bytes,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
if not self.flake.is_local:
|
if not self.flake.is_local:
|
||||||
msg = f"in_flake fact storage is only supported for local flakes: {self.flake}"
|
msg = f"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
folder = self.directory(generator, var.name)
|
folder = self.directory(generator, var.name)
|
||||||
file_path = folder / "value"
|
file_path = folder / "value"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
inputs.Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||||
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
inputs.nixpkgs.follows = "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU/nixpkgs";
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{ self, clan-core, ... }:
|
{ self, Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU, ... }:
|
||||||
let
|
let
|
||||||
clan = clan-core.lib.clan ({
|
clan = Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.lib.clan ({
|
||||||
inherit self;
|
inherit self;
|
||||||
imports = [
|
imports = [
|
||||||
./clan.nix
|
./clan.nix
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from time import time
|
from time import time
|
||||||
@@ -25,14 +26,13 @@ log = logging.getLogger(__name__)
|
|||||||
BuildOn = Literal["auto", "local", "remote"]
|
BuildOn = Literal["auto", "local", "remote"]
|
||||||
|
|
||||||
|
|
||||||
Step = Literal[
|
class Step(str, Enum):
|
||||||
"generators",
|
GENERATORS = "generators"
|
||||||
"upload-secrets",
|
UPLOAD_SECRETS = "upload-secrets"
|
||||||
"nixos-anywhere",
|
NIXOS_ANYWHERE = "nixos-anywhere"
|
||||||
"formatting",
|
FORMATTING = "formatting"
|
||||||
"rebooting",
|
REBOOTING = "rebooting"
|
||||||
"installing",
|
INSTALLING = "installing"
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def notify_install_step(current: Step) -> None:
|
def notify_install_step(current: Step) -> None:
|
||||||
@@ -93,7 +93,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify the UI about what we are doing
|
# Notify the UI about what we are doing
|
||||||
notify_install_step("generators")
|
notify_install_step(Step.GENERATORS)
|
||||||
generate_facts([machine])
|
generate_facts([machine])
|
||||||
run_generators([machine], generators=None, full_closure=False)
|
run_generators([machine], generators=None, full_closure=False)
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
|||||||
upload_dir.mkdir(parents=True)
|
upload_dir.mkdir(parents=True)
|
||||||
|
|
||||||
# Notify the UI about what we are doing
|
# Notify the UI about what we are doing
|
||||||
notify_install_step("upload-secrets")
|
notify_install_step(Step.UPLOAD_SECRETS)
|
||||||
machine.secret_facts_store.upload(upload_dir)
|
machine.secret_facts_store.upload(upload_dir)
|
||||||
machine.secret_vars_store.populate_dir(
|
machine.secret_vars_store.populate_dir(
|
||||||
machine.name,
|
machine.name,
|
||||||
@@ -214,15 +214,15 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
|||||||
cmd,
|
cmd,
|
||||||
)
|
)
|
||||||
|
|
||||||
default_install_steps: dict[str, Step] = {
|
install_steps = {
|
||||||
"kexec": "nixos-anywhere",
|
"kexec": Step.NIXOS_ANYWHERE,
|
||||||
"disko": "formatting",
|
"disko": Step.FORMATTING,
|
||||||
"install": "installing",
|
"install": Step.INSTALLING,
|
||||||
"reboot": "rebooting",
|
"reboot": Step.REBOOTING,
|
||||||
}
|
}
|
||||||
|
|
||||||
def run_phase(phase: str) -> None:
|
def run_phase(phase: str) -> None:
|
||||||
notification = default_install_steps.get(phase, "nixos-anywhere")
|
notification = install_steps.get(phase, Step.NIXOS_ANYWHERE)
|
||||||
notify_install_step(notification)
|
notify_install_step(notification)
|
||||||
run(
|
run(
|
||||||
[*cmd, "--phases", phase],
|
[*cmd, "--phases", phase],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Unknown:
|
|||||||
|
|
||||||
|
|
||||||
InventoryInstanceModuleNameType = str
|
InventoryInstanceModuleNameType = str
|
||||||
InventoryInstanceModuleInputType = str
|
InventoryInstanceModuleInputType = str | None
|
||||||
|
|
||||||
class InventoryInstanceModule(TypedDict):
|
class InventoryInstanceModule(TypedDict):
|
||||||
name: str
|
name: str
|
||||||
@@ -163,7 +163,7 @@ class Template(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
ClanDirectoryType = dict[str, Any] | list[Any] | bool | float | int | str
|
ClanDirectoryType = dict[str, Any] | list[Any] | bool | float | int | str | None
|
||||||
ClanInventoryType = Inventory
|
ClanInventoryType = Inventory
|
||||||
ClanMachinesType = dict[str, Unknown]
|
ClanMachinesType = dict[str, Unknown]
|
||||||
ClanMetaType = Unknown
|
ClanMetaType = Unknown
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
from clan_lib.api import API
|
|
||||||
from clan_lib.errors import ClanError
|
|
||||||
from clan_lib.flake.flake import Flake
|
|
||||||
from clan_lib.nix_models.clan import (
|
|
||||||
InventoryInstanceModule,
|
|
||||||
InventoryInstanceRolesType,
|
|
||||||
InventoryInstancesType,
|
|
||||||
InventoryMachinesType,
|
|
||||||
)
|
|
||||||
from clan_lib.persist.inventory_store import InventoryStore
|
|
||||||
from clan_lib.persist.util import set_value_by_path
|
|
||||||
from clan_lib.services.modules import (
|
|
||||||
get_service_module,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: move imports out of cli/__init__.py causing import cycles
|
|
||||||
# from clan_lib.machines.actions import list_machines
|
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
|
||||||
def list_service_instances(flake: Flake) -> InventoryInstancesType:
|
|
||||||
"""Returns all currently present service instances including their full configuration"""
|
|
||||||
inventory_store = InventoryStore(flake)
|
|
||||||
inventory = inventory_store.read()
|
|
||||||
return inventory.get("instances", {})
|
|
||||||
|
|
||||||
|
|
||||||
def collect_tags(machines: InventoryMachinesType) -> set[str]:
|
|
||||||
res = set()
|
|
||||||
for machine in machines.values():
|
|
||||||
res |= set(machine.get("tags", []))
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
# Removed 'module' ref - Needs to be passed seperately
|
|
||||||
class InstanceConfig(TypedDict):
|
|
||||||
roles: InventoryInstanceRolesType
|
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
|
||||||
def create_service_instance(
|
|
||||||
flake: Flake,
|
|
||||||
module_ref: InventoryInstanceModule,
|
|
||||||
instance_name: str,
|
|
||||||
instance_config: InstanceConfig,
|
|
||||||
) -> None:
|
|
||||||
module = get_service_module(flake, module_ref)
|
|
||||||
|
|
||||||
inventory_store = InventoryStore(flake)
|
|
||||||
inventory = inventory_store.read()
|
|
||||||
|
|
||||||
instances = inventory.get("instances", {})
|
|
||||||
if instance_name in instances:
|
|
||||||
msg = f"service instance '{instance_name}' already exists."
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
target_roles = instance_config.get("roles")
|
|
||||||
if not target_roles:
|
|
||||||
msg = "Creating a service instance requires adding roles"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
available_roles = set(module.get("roles", {}).keys())
|
|
||||||
|
|
||||||
unavailable_roles = list(filter(lambda r: r not in available_roles, target_roles))
|
|
||||||
if unavailable_roles:
|
|
||||||
msg = f"Unknown roles: {unavailable_roles}. Use one of {available_roles}"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
role_configs = instance_config.get("roles")
|
|
||||||
if not role_configs:
|
|
||||||
return
|
|
||||||
|
|
||||||
## Validate machine references
|
|
||||||
all_machines = inventory.get("machines", {})
|
|
||||||
available_machine_refs = set(all_machines.keys())
|
|
||||||
available_tag_refs = collect_tags(all_machines)
|
|
||||||
|
|
||||||
for role_name, role_members in role_configs.items():
|
|
||||||
machine_refs = role_members.get("machines")
|
|
||||||
msg = f"Role: '{role_name}' - "
|
|
||||||
if machine_refs:
|
|
||||||
unavailable_machines = list(
|
|
||||||
filter(lambda m: m not in available_machine_refs, machine_refs),
|
|
||||||
)
|
|
||||||
if unavailable_machines:
|
|
||||||
msg += f"Unknown machine reference: {unavailable_machines}. Use one of {available_machine_refs}"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
tag_refs = role_members.get("tags")
|
|
||||||
if tag_refs:
|
|
||||||
unavailable_tags = list(
|
|
||||||
filter(lambda m: m not in available_tag_refs, tag_refs),
|
|
||||||
)
|
|
||||||
|
|
||||||
if unavailable_tags:
|
|
||||||
msg += (
|
|
||||||
f"Unknown tags: {unavailable_tags}. Use one of {available_tag_refs}"
|
|
||||||
)
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
# TODO:
|
|
||||||
# Validate instance_config roles settings against role schema
|
|
||||||
|
|
||||||
set_value_by_path(inventory, f"instances.{instance_name}", instance_config)
|
|
||||||
set_value_by_path(inventory, f"instances.{instance_name}.module", module_ref)
|
|
||||||
inventory_store.write(
|
|
||||||
inventory,
|
|
||||||
message=f"services: instance '{instance_name}' init",
|
|
||||||
)
|
|
||||||
@@ -11,6 +11,7 @@ from clan_lib.nix_models.clan import (
|
|||||||
InventoryInstanceModule,
|
InventoryInstanceModule,
|
||||||
InventoryInstanceModuleType,
|
InventoryInstanceModuleType,
|
||||||
InventoryInstanceRolesType,
|
InventoryInstanceRolesType,
|
||||||
|
InventoryInstancesType,
|
||||||
)
|
)
|
||||||
from clan_lib.persist.inventory_store import InventoryStore
|
from clan_lib.persist.inventory_store import InventoryStore
|
||||||
from clan_lib.persist.util import set_value_by_path
|
from clan_lib.persist.util import set_value_by_path
|
||||||
@@ -60,7 +61,7 @@ class ModuleManifest:
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "ModuleManifest":
|
def from_dict(cls, data: dict[str, Any]) -> "ModuleManifest":
|
||||||
"""Create an instance of this class from a dictionary.
|
"""Create an instance of this class from a dictionary.
|
||||||
Drops any keys that are not defined in the dataclass.
|
Drops any keys that are not defined in the dataclass.
|
||||||
"""
|
"""
|
||||||
@@ -147,106 +148,159 @@ def extract_frontmatter[T](
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModuleInfo(TypedDict):
|
class ModuleInfo:
|
||||||
manifest: ModuleManifest
|
manifest: ModuleManifest
|
||||||
roles: dict[str, None]
|
roles: dict[str, None]
|
||||||
|
|
||||||
|
|
||||||
class Module(TypedDict):
|
@dataclass
|
||||||
module: InventoryInstanceModule
|
class Module:
|
||||||
|
# To use this module specify: InventoryInstanceModule :: { input, name } (foreign key)
|
||||||
|
usage_ref: InventoryInstanceModule
|
||||||
info: ModuleInfo
|
info: ModuleInfo
|
||||||
|
native: bool
|
||||||
|
instance_refs: list[str]
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@dataclass
|
||||||
def list_service_modules(flake: Flake) -> list[Module]:
|
class ClanModules:
|
||||||
"""Show information about a module"""
|
modules: list[Module]
|
||||||
modules = flake.select("clanInternals.inventoryClass.modulesPerSource")
|
core_input_name: str
|
||||||
|
|
||||||
res: list[Module] = []
|
|
||||||
for input_name, module_set in modules.items():
|
def find_instance_refs_for_module(
|
||||||
for module_name, module_info in module_set.items():
|
instances: InventoryInstancesType,
|
||||||
res.append(
|
module_ref: InventoryInstanceModule,
|
||||||
Module(
|
core_input_name: str,
|
||||||
module={"name": module_name, "input": input_name},
|
) -> list[str]:
|
||||||
info=ModuleInfo(
|
"""Find all usages of a given module by its module_ref
|
||||||
manifest=ModuleManifest.from_dict(
|
|
||||||
module_info.get("manifest"),
|
If the module is native:
|
||||||
),
|
module_ref.input := None
|
||||||
roles=module_info.get("roles", {}),
|
<instance>.module.name := None
|
||||||
),
|
|
||||||
)
|
If module is from explicit input
|
||||||
)
|
<instance>.module.name != None
|
||||||
|
module_ref.input could be None, if explicit input refers to a native module
|
||||||
|
|
||||||
|
"""
|
||||||
|
res: list[str] = []
|
||||||
|
for instance_name, instance in instances.items():
|
||||||
|
local_ref = instance.get("module")
|
||||||
|
if not local_ref:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_name: str = local_ref.get("name", instance_name)
|
||||||
|
local_input: str | None = local_ref.get("input")
|
||||||
|
|
||||||
|
# Normal match
|
||||||
|
if (
|
||||||
|
local_name == module_ref.get("name")
|
||||||
|
and local_input == module_ref.get("input")
|
||||||
|
) or (local_input == core_input_name and local_name == module_ref.get("name")):
|
||||||
|
res.append(instance_name)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def get_service_module(
|
def list_service_modules(flake: Flake) -> ClanModules:
|
||||||
flake: Flake,
|
"""Show information about a module"""
|
||||||
module_ref: InventoryInstanceModuleType,
|
# inputName.moduleName -> ModuleInfo
|
||||||
) -> ModuleInfo:
|
modules: dict[str, dict[str, Any]] = flake.select(
|
||||||
"""Returns the module information for a given module reference
|
"clanInternals.inventoryClass.modulesPerSource"
|
||||||
|
)
|
||||||
|
|
||||||
:param module_ref: The module reference to get the information for
|
# moduleName -> ModuleInfo
|
||||||
:return: Dict of module information
|
builtin_modules: dict[str, Any] = flake.select(
|
||||||
:raises ClanError: If the module_ref is invalid or missing required fields
|
"clanInternals.inventoryClass.staticModules"
|
||||||
"""
|
)
|
||||||
input_name, module_name = check_service_module_ref(flake, module_ref)
|
inventory_store = InventoryStore(flake)
|
||||||
|
instances = inventory_store.read().get("instances", {})
|
||||||
|
|
||||||
avilable_modules = list_service_modules(flake)
|
first_name, first_module = next(iter(builtin_modules.items()))
|
||||||
module_set: list[Module] = [
|
clan_input_name = None
|
||||||
m for m in avilable_modules if m["module"].get("input", None) == input_name
|
for input_name, module_set in modules.items():
|
||||||
]
|
if first_name in module_set:
|
||||||
|
# Compare the manifest name
|
||||||
|
module_set[first_name]["manifest"]["name"] = first_module["manifest"][
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
clan_input_name = input_name
|
||||||
|
break
|
||||||
|
|
||||||
if not module_set:
|
if clan_input_name is None:
|
||||||
msg = f"Module set for input '{input_name}' not found"
|
msg = "Could not determine the clan-core input name"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
|
res: list[Module] = []
|
||||||
|
for input_name, module_set in modules.items():
|
||||||
|
for module_name, module_info in module_set.items():
|
||||||
|
module_ref = InventoryInstanceModule(
|
||||||
|
{
|
||||||
|
"name": module_name,
|
||||||
|
"input": None if input_name == clan_input_name else input_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
res.append(
|
||||||
|
Module(
|
||||||
|
instance_refs=find_instance_refs_for_module(
|
||||||
|
instances, module_ref, clan_input_name
|
||||||
|
),
|
||||||
|
usage_ref=module_ref,
|
||||||
|
info=ModuleInfo(
|
||||||
|
roles=module_info.get("roles", {}),
|
||||||
|
manifest=ModuleManifest.from_dict(module_info["manifest"]),
|
||||||
|
),
|
||||||
|
native=(input_name == clan_input_name),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if module is None:
|
return ClanModules(res, clan_input_name)
|
||||||
msg = f"Module '{module_name}' not found in input '{input_name}'"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
return module["info"]
|
|
||||||
|
|
||||||
|
|
||||||
def check_service_module_ref(
|
def resolve_service_module_ref(
|
||||||
flake: Flake,
|
flake: Flake,
|
||||||
module_ref: InventoryInstanceModuleType,
|
module_ref: InventoryInstanceModuleType,
|
||||||
) -> tuple[str, str]:
|
) -> Module:
|
||||||
"""Checks if the module reference is valid
|
"""Checks if the module reference is valid
|
||||||
|
|
||||||
:param module_ref: The module reference to check
|
:param module_ref: The module reference to check
|
||||||
:raises ClanError: If the module_ref is invalid or missing required fields
|
:raises ClanError: If the module_ref is invalid or missing required fields
|
||||||
"""
|
"""
|
||||||
avilable_modules = list_service_modules(flake)
|
service_modules = list_service_modules(flake)
|
||||||
|
avilable_modules = service_modules.modules
|
||||||
|
|
||||||
input_ref = module_ref.get("input", None)
|
input_ref = module_ref.get("input", None)
|
||||||
if input_ref is None:
|
|
||||||
msg = "Setting module_ref.input is currently required"
|
|
||||||
raise ClanError(msg)
|
|
||||||
|
|
||||||
module_set = [
|
if input_ref is None or input_ref == service_modules.core_input_name:
|
||||||
m for m in avilable_modules if m["module"].get("input", None) == input_ref
|
# Take only the native modules
|
||||||
]
|
module_set = [m for m in avilable_modules if m.native]
|
||||||
|
else:
|
||||||
|
# Match the input ref
|
||||||
|
module_set = [
|
||||||
|
m for m in avilable_modules if m.usage_ref.get("input", None) == input_ref
|
||||||
|
]
|
||||||
|
|
||||||
if module_set is None:
|
if not module_set:
|
||||||
inputs = {m["module"].get("input") for m in avilable_modules}
|
inputs = {m.usage_ref.get("input") for m in avilable_modules}
|
||||||
msg = f"module set for input '{input_ref}' not found"
|
msg = f"module set for input '{input_ref}' not found"
|
||||||
msg += f"\nAvilable input_refs: {inputs}"
|
msg += f"\nAvilable input_refs: {inputs}"
|
||||||
|
msg += "\nOmit the input field to use the built-in modules\n"
|
||||||
|
msg += "\n".join([m.usage_ref["name"] for m in avilable_modules if m.native])
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
module_name = module_ref.get("name")
|
module_name = module_ref.get("name")
|
||||||
if not module_name:
|
if not module_name:
|
||||||
msg = "Module name is required in module_ref"
|
msg = "Module name is required in module_ref"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
|
|
||||||
|
module = next((m for m in module_set if m.usage_ref["name"] == module_name), None)
|
||||||
if module is None:
|
if module is None:
|
||||||
msg = f"module with name '{module_name}' not found"
|
msg = f"module with name '{module_name}' not found"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
return (input_ref, module_name)
|
return module
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
@@ -260,7 +314,16 @@ def get_service_module_schema(
|
|||||||
:return: Dict of schemas for the service module roles
|
:return: Dict of schemas for the service module roles
|
||||||
:raises ClanError: If the module_ref is invalid or missing required fields
|
:raises ClanError: If the module_ref is invalid or missing required fields
|
||||||
"""
|
"""
|
||||||
input_name, module_name = check_service_module_ref(flake, module_ref)
|
input_name, module_name = module_ref.get("input"), module_ref["name"]
|
||||||
|
module = resolve_service_module_ref(flake, module_ref)
|
||||||
|
|
||||||
|
if module is None:
|
||||||
|
msg = f"Module '{module_name}' not found in input '{input_name}'"
|
||||||
|
raise ClanError(msg)
|
||||||
|
|
||||||
|
if input_name is None:
|
||||||
|
msg = "Not implemented for: input_name is None"
|
||||||
|
raise ClanError(msg)
|
||||||
|
|
||||||
return flake.select(
|
return flake.select(
|
||||||
f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}",
|
f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}",
|
||||||
@@ -274,7 +337,8 @@ def create_service_instance(
|
|||||||
roles: InventoryInstanceRolesType,
|
roles: InventoryInstanceRolesType,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show information about a module"""
|
"""Show information about a module"""
|
||||||
input_name, module_name = check_service_module_ref(flake, module_ref)
|
input_name, module_name = module_ref.get("input"), module_ref["name"]
|
||||||
|
module = resolve_service_module_ref(flake, module_ref)
|
||||||
|
|
||||||
inventory_store = InventoryStore(flake)
|
inventory_store = InventoryStore(flake)
|
||||||
|
|
||||||
@@ -295,10 +359,10 @@ def create_service_instance(
|
|||||||
all_machines = inventory.get("machines", {})
|
all_machines = inventory.get("machines", {})
|
||||||
available_machine_refs = set(all_machines.keys())
|
available_machine_refs = set(all_machines.keys())
|
||||||
|
|
||||||
schema = get_service_module_schema(flake, module_ref)
|
allowed_roles = module.info.roles
|
||||||
for role_name, role_members in roles.items():
|
for role_name, role_members in roles.items():
|
||||||
if role_name not in schema:
|
if role_name not in allowed_roles:
|
||||||
msg = f"Role '{role_name}' is not defined in the module schema"
|
msg = f"Role '{role_name}' is not defined in the module"
|
||||||
raise ClanError(msg)
|
raise ClanError(msg)
|
||||||
|
|
||||||
machine_refs = role_members.get("machines")
|
machine_refs = role_members.get("machines")
|
||||||
@@ -315,13 +379,21 @@ def create_service_instance(
|
|||||||
# settings = role_members.get("settings", {})
|
# settings = role_members.get("settings", {})
|
||||||
|
|
||||||
# Create a new instance with the given roles
|
# Create a new instance with the given roles
|
||||||
new_instance: InventoryInstance = {
|
if not input_name:
|
||||||
"module": {
|
new_instance: InventoryInstance = {
|
||||||
"name": module_name,
|
"module": {
|
||||||
"input": input_name,
|
"name": module_name,
|
||||||
},
|
},
|
||||||
"roles": roles,
|
"roles": roles,
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
new_instance = {
|
||||||
|
"module": {
|
||||||
|
"name": module_name,
|
||||||
|
"input": input_name,
|
||||||
|
},
|
||||||
|
"roles": roles,
|
||||||
|
}
|
||||||
|
|
||||||
set_value_by_path(inventory, f"instances.{instance_name}", new_instance)
|
set_value_by_path(inventory, f"instances.{instance_name}", new_instance)
|
||||||
inventory_store.write(
|
inventory_store.write(
|
||||||
@@ -331,11 +403,31 @@ def create_service_instance(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InventoryInstanceInfo:
|
||||||
|
resolved: Module
|
||||||
|
module: InventoryInstanceModule
|
||||||
|
roles: InventoryInstanceRolesType
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def list_service_instances(
|
def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]:
|
||||||
flake: Flake,
|
"""Returns all currently present service instances including their full configuration"""
|
||||||
) -> dict[str, InventoryInstance]:
|
|
||||||
"""Show information about a module"""
|
|
||||||
inventory_store = InventoryStore(flake)
|
inventory_store = InventoryStore(flake)
|
||||||
inventory = inventory_store.read()
|
inventory = inventory_store.read()
|
||||||
return inventory.get("instances", {})
|
|
||||||
|
instances = inventory.get("instances", {})
|
||||||
|
res: dict[str, InventoryInstanceInfo] = {}
|
||||||
|
for instance_name, instance in instances.items():
|
||||||
|
persisted_ref = instance.get("module", {"name": instance_name})
|
||||||
|
module = resolve_service_module_ref(flake, persisted_ref)
|
||||||
|
|
||||||
|
if module is None:
|
||||||
|
msg = f"Module for instance '{instance_name}' not found"
|
||||||
|
raise ClanError(msg)
|
||||||
|
res[instance_name] = InventoryInstanceInfo(
|
||||||
|
resolved=module,
|
||||||
|
module=persisted_ref,
|
||||||
|
roles=instance.get("roles", {}),
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|||||||
105
pkgs/clan-cli/clan_lib/services/modules_test.py
Normal file
105
pkgs/clan-cli/clan_lib/services/modules_test.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from clan_cli.tests.fixtures_flakes import nested_dict
|
||||||
|
from clan_lib.flake.flake import Flake
|
||||||
|
from clan_lib.services.modules import list_service_instances, list_service_modules
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from clan_lib.nix_models.clan import Clan
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.with_core
|
||||||
|
def test_list_service_instances(
|
||||||
|
clan_flake: Callable[..., Flake],
|
||||||
|
) -> None:
|
||||||
|
# ATTENTION! This method lacks Typechecking
|
||||||
|
config = nested_dict()
|
||||||
|
# explicit module selection
|
||||||
|
# We use this random string in test to avoid code dependencies on the input name
|
||||||
|
config["inventory"]["instances"]["foo"]["module"]["input"] = (
|
||||||
|
"Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
|
||||||
|
)
|
||||||
|
config["inventory"]["instances"]["foo"]["module"]["name"] = "sshd"
|
||||||
|
# input = null
|
||||||
|
config["inventory"]["instances"]["bar"]["module"]["input"] = None
|
||||||
|
config["inventory"]["instances"]["bar"]["module"]["name"] = "sshd"
|
||||||
|
|
||||||
|
# Omit input
|
||||||
|
config["inventory"]["instances"]["baz"]["module"]["name"] = "sshd"
|
||||||
|
# external input
|
||||||
|
flake = clan_flake(config)
|
||||||
|
|
||||||
|
service_modules = list_service_modules(flake)
|
||||||
|
|
||||||
|
assert len(service_modules.modules)
|
||||||
|
assert any(m.usage_ref["name"] == "sshd" for m in service_modules.modules)
|
||||||
|
|
||||||
|
instances = list_service_instances(flake)
|
||||||
|
|
||||||
|
assert set(instances.keys()) == {"foo", "bar", "baz"}
|
||||||
|
|
||||||
|
# Reference to a built-in module
|
||||||
|
assert instances["foo"].resolved.usage_ref.get("input") is None
|
||||||
|
assert instances["foo"].resolved.usage_ref.get("name") == "sshd"
|
||||||
|
assert instances["foo"].resolved.info.manifest.name == "clan-core/sshd"
|
||||||
|
# Actual module
|
||||||
|
assert (
|
||||||
|
instances["foo"].module.get("input")
|
||||||
|
== "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Module exposes the input name?
|
||||||
|
assert instances["bar"].resolved.usage_ref.get("input") is None
|
||||||
|
assert instances["bar"].resolved.usage_ref.get("name") == "sshd"
|
||||||
|
|
||||||
|
assert instances["baz"].resolved.usage_ref.get("input") is None
|
||||||
|
assert instances["baz"].resolved.usage_ref.get("name") == "sshd"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.with_core
|
||||||
|
def test_list_service_modules(
|
||||||
|
clan_flake: Callable[..., Flake],
|
||||||
|
) -> None:
|
||||||
|
# Nice! This is typechecked :)
|
||||||
|
clan_config: Clan = {
|
||||||
|
"inventory": {
|
||||||
|
"instances": {
|
||||||
|
# No module spec -> resolves to clan-core/admin
|
||||||
|
"admin": {},
|
||||||
|
# Partial module spec
|
||||||
|
"admin2": {"module": {"name": "admin"}},
|
||||||
|
# Full explicit module spec
|
||||||
|
"admin3": {
|
||||||
|
"module": {
|
||||||
|
"name": "admin",
|
||||||
|
"input": "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flake = clan_flake(clan_config)
|
||||||
|
|
||||||
|
service_modules = list_service_modules(flake)
|
||||||
|
|
||||||
|
# Detects the input name right
|
||||||
|
assert service_modules.core_input_name == "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU"
|
||||||
|
assert len(service_modules.modules)
|
||||||
|
|
||||||
|
admin_service = next(
|
||||||
|
m for m in service_modules.modules if m.usage_ref.get("name") == "admin"
|
||||||
|
)
|
||||||
|
assert admin_service
|
||||||
|
|
||||||
|
assert admin_service.usage_ref == {"name": "admin", "input": None}
|
||||||
|
assert set(admin_service.instance_refs) == {"admin", "admin2", "admin3"}
|
||||||
|
|
||||||
|
# Negative test: Assert not used
|
||||||
|
sshd_service = next(
|
||||||
|
m for m in service_modules.modules if m.usage_ref.get("name") == "sshd"
|
||||||
|
)
|
||||||
|
assert sshd_service
|
||||||
|
assert sshd_service.usage_ref == {"name": "sshd", "input": None}
|
||||||
|
assert set(sshd_service.instance_refs) == set({})
|
||||||
@@ -214,10 +214,12 @@ def test_clan_create_api(
|
|||||||
store = InventoryStore(clan_dir_flake)
|
store = InventoryStore(clan_dir_flake)
|
||||||
inventory = store.read()
|
inventory = store.read()
|
||||||
|
|
||||||
modules = list_service_modules(clan_dir_flake)
|
service_modules = list_service_modules(clan_dir_flake)
|
||||||
|
|
||||||
admin_module = next(m for m in modules if m["module"]["name"] == "admin")
|
admin_module = next(
|
||||||
assert admin_module["info"]["manifest"].name == "clan-core/admin"
|
m for m in service_modules.modules if m.usage_ref.get("name") == "admin"
|
||||||
|
)
|
||||||
|
assert admin_module.info.manifest.name == "clan-core/admin"
|
||||||
|
|
||||||
set_value_by_path(inventory, "instances", inventory_conf.instances)
|
set_value_by_path(inventory, "instances", inventory_conf.instances)
|
||||||
store.write(
|
store.write(
|
||||||
|
|||||||
@@ -218,13 +218,12 @@ def get_field_def(
|
|||||||
default_factory: str | None = None,
|
default_factory: str | None = None,
|
||||||
type_appendix: str = "",
|
type_appendix: str = "",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
if "None" in field_types or default or default_factory:
|
_field_types = set(field_types)
|
||||||
if "None" in field_types:
|
if "None" in _field_types or default or default_factory:
|
||||||
field_types.remove("None")
|
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix
|
||||||
serialised_types = " | ".join(sort_types(field_types)) + type_appendix
|
|
||||||
serialised_types = f"{serialised_types}"
|
serialised_types = f"{serialised_types}"
|
||||||
else:
|
else:
|
||||||
serialised_types = " | ".join(sort_types(field_types)) + type_appendix
|
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix
|
||||||
|
|
||||||
return (field_name, serialised_types)
|
return (field_name, serialised_types)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ let
|
|||||||
self.nixosModules.installer
|
self.nixosModules.installer
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# We don't need state-version in a live installer, we can just set nixos.release directly
|
||||||
|
clan.core.settings.state-version.enable = false;
|
||||||
system.stateVersion = config.system.nixos.release;
|
system.stateVersion = config.system.nixos.release;
|
||||||
nixpkgs.pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux;
|
nixpkgs.pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
clan.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||||
nixpkgs.follows = "clan/nixpkgs";
|
nixpkgs.follows = "clan-core/nixpkgs";
|
||||||
|
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
flake-parts.inputs.nixpkgs-lib.follows = "clan/nixpkgs";
|
flake-parts.inputs.nixpkgs-lib.follows = "clan-core/nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
|
|||||||
Reference in New Issue
Block a user