Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes Kirschbauer
8584f3247a machine/install: fix type issues 2025-08-29 11:03:24 +02:00
Sacha Korban
d3534a2b72 fix: check if phases are non-default when running 2025-08-29 18:27:03 +10:00
54 changed files with 602 additions and 1545 deletions

12
devFlake/flake.lock generated
View File

@@ -84,11 +84,11 @@
}, },
"nixpkgs-dev": { "nixpkgs-dev": {
"locked": { "locked": {
"lastModified": 1756662818, "lastModified": 1756400612,
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=", "narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09", "rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -165,11 +165,11 @@
"nixpkgs": [] "nixpkgs": []
}, },
"locked": { "locked": {
"lastModified": 1756662192, "lastModified": 1755934250,
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"type": "github" "type": "github"
}, },
"original": { "original": {

20
flake.lock generated
View File

@@ -13,11 +13,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1756695982, "lastModified": 1756091210,
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=", "narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb", "rev": "eb831bca21476fa8f6df26cb39e076842634700d",
"type": "tarball", "type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/cc8f26e7e6c2dc985526ba59b286ae5a83168cdb.tar.gz" "url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/eb831bca21476fa8f6df26cb39e076842634700d.tar.gz"
}, },
"original": { "original": {
"type": "tarball", "type": "tarball",
@@ -99,11 +99,11 @@
}, },
"nixos-facter-modules": { "nixos-facter-modules": {
"locked": { "locked": {
"lastModified": 1756491981, "lastModified": 1756291602,
"narHash": "sha256-lXyDAWPw/UngVtQfgQ8/nrubs2r+waGEYIba5UX62+k=", "narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixos-facter-modules", "repo": "nixos-facter-modules",
"rev": "c1b29520945d3e148cd96618c8a0d1f850965d8c", "rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -181,11 +181,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1756662192, "lastModified": 1755934250,
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -245,8 +245,6 @@ 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;

View File

@@ -23,12 +23,6 @@ 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;

View File

@@ -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 = lib.mkDefault true; clan.core.settings.state-version.enable = 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

View File

@@ -12,14 +12,8 @@ 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
&& ( && (
let (!isUnstable && !kernelPackages.zfs.meta.broken)
zfsPackage = || (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
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 (
@@ -30,5 +24,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 = lib.mkIf (lib.meta.availableOn pkgs.hostPlatform pkgs.zfs) latestKernelPackage; boot.kernelPackages = latestKernelPackage;
} }

View File

@@ -48,10 +48,6 @@ let
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2"; url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk="; hash = "sha256-80LKbD8ll+bA/NhLPz7WTTzlvbbQrxnRkNZFpVixzyk=";
}; };
commitMono_ttf = fetchurl {
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.ttf";
hash = "sha256-mN6akBFjp2mBLDzy8bhtY6mKnO1nINdHqmZSaIQHw08=";
};
in in
runCommand "" { } '' runCommand "" { } ''
@@ -66,5 +62,4 @@ runCommand "" { } ''
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2 cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
cp ${commitMono} $out/CommitMonoV143-VF.woff2 cp ${commitMono} $out/CommitMonoV143-VF.woff2
cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf
'' ''

View File

@@ -23,7 +23,6 @@
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0", "three": "^0.176.0",
"troika-three-text": "^0.52.4",
"valibot": "^1.1.0" "valibot": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -3808,15 +3807,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -7538,15 +7528,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/requires-port": { "node_modules/requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -8674,36 +8655,6 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/troika-three-text": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
"troika-three-utils": "^0.52.4",
"troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-three-utils": {
"version": "0.52.4",
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-worker-utils": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -9317,12 +9268,6 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/webgl-sdf-generator": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@@ -80,7 +80,6 @@
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0", "three": "^0.176.0",
"troika-three-text": "^0.52.4",
"valibot": "^1.1.0" "valibot": "^1.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@@ -1,33 +0,0 @@
.list {
display: flex;
width: 113px;
padding: 8px;
flex-direction: column;
align-items: flex-start;
border-radius: 5px;
border: 1px solid var(--clr-border-def-2, #d8e8eb);
background: var(--clr-bg-def-1, #fff);
box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.24);
}
.item {
max-height: 28px;
height: 28px;
padding: 4px 8px;
cursor: pointer;
display: flex;
align-items: center;
align-self: stretch;
gap: 4px;
&:hover {
@apply bg-def-3;
border-radius: 2px;
}
&[aria-disabled="true"] {
cursor: not-allowed;
pointer-events: none;
}
}

View File

@@ -1,61 +0,0 @@
import { onCleanup, onMount } from "solid-js";
import styles from "./ContextMenu.module.css";
import { Typography } from "../Typography/Typography";
export const Menu = (props: {
x: number;
y: number;
onSelect: (option: "move") => void;
close: () => void;
intersect: string[];
}) => {
let ref: HTMLUListElement;
const handleClickOutside = (e: MouseEvent) => {
if (!ref.contains(e.target as Node)) {
props.close();
}
};
onMount(() => {
document.addEventListener("mousedown", handleClickOutside);
});
onCleanup(() =>
document.removeEventListener("mousedown", handleClickOutside),
);
const currentMachine = () => props.intersect.at(0) || null;
return (
<ul
ref={(el) => (ref = el)}
style={{
position: "absolute",
top: `${props.y}px`,
left: `${props.x}px`,
"z-index": 1000,
"pointer-events": "auto",
}}
class={styles.list}
>
<li
class={styles.item}
aria-disabled={!currentMachine()}
onClick={() => {
console.log("Move clicked", currentMachine());
props.onSelect("move");
props.close();
}}
>
<Typography
hierarchy="label"
size="s"
weight="bold"
color={currentMachine() ? "primary" : "quaternary"}
>
Move
</Typography>
</li>
</ul>
);
};

View File

@@ -3,13 +3,12 @@ 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, Show, useContext } from "solid-js"; import { For, 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 { Button } from "../Button/Button"; import { ClanContext } from "@/src/routes/Clan/Clan";
import { useClanContext } from "@/src/routes/Clan/Clan";
interface MachineProps { interface MachineProps {
clanURI: string; clanURI: string;
@@ -59,7 +58,10 @@ const MachineRoute = (props: MachineProps) => {
export const SidebarBody = (props: SidebarProps) => { export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI(); const clanURI = useClanURI();
const ctx = useClanContext(); const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const sectionLabels = (props.staticSections || []).map( const sectionLabels = (props.staticSections || []).map(
(section) => section.title, (section) => section.title,
@@ -69,15 +71,6 @@ 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
@@ -107,42 +100,18 @@ export const SidebarBody = (props: SidebarProps) => {
</Accordion.Trigger> </Accordion.Trigger>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="content"> <Accordion.Content class="content">
<Show <nav>
when={machines()} <For each={Object.entries(ctx.machinesQuery.data || {})}>
fallback={ {([id, machine]) => (
<div class="flex w-full flex-col items-center justify-center gap-2.5"> <MachineRoute
<Typography clanURI={clanURI}
hierarchy="body" machineID={id}
size="s" name={machine.name || id}
weight="medium" serviceCount={0}
inverted />
> )}
No machines yet </For>
</Typography> </nav>
<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>

View File

@@ -3,12 +3,12 @@ import Icon from "@/src/components/Icon/Icon";
import { DropdownMenu } from "@kobalte/core/dropdown-menu"; import { DropdownMenu } from "@kobalte/core/dropdown-menu";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import { createSignal, For, Show, Suspense } from "solid-js"; import { createSignal, For, Show, Suspense, useContext } from "solid-js";
import { navigateToOnboarding } from "@/src/hooks/clan"; import { navigateToOnboarding } from "@/src/hooks/clan";
import { setActiveClanURI } from "@/src/stores/clan"; import { setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
import { ClanContext } from "@/src/routes/Clan/Clan";
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal"; import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
import { useClanContext } from "@/src/routes/Clan/Clan";
export const SidebarHeader = () => { export const SidebarHeader = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,7 +17,11 @@ export const SidebarHeader = () => {
const [showSettings, setShowSettings] = createSignal(false); const [showSettings, setShowSettings] = createSignal(false);
// get information about the current active clan // get information about the current active clan
const ctx = useClanContext(); const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("SidebarContext not found");
}
const clanChar = () => const clanChar = () =>
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase(); ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();

View File

@@ -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>

View File

@@ -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/InstallMachine/InstallMachine"; import { InstallModal } from "@/src/workflows/Install/install";
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";

View File

@@ -143,7 +143,6 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineState>(() => ({ return useQuery<MachineState>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"], queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
staleTime: 60_000, // 1 minute stale time
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("get_machine_state", { const apiCall = client.fetch("get_machine_state", {
machine: { machine: {
@@ -456,12 +455,14 @@ export const useMachineGenerators = (
], ],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_generators", { const call = client.fetch("get_generators", {
machine: { machines: [
name: machineName, {
flake: { name: machineName,
identifier: clanUri, flake: {
identifier: clanUri,
},
}, },
}, ],
full_closure: true, // TODO: Make this configurable full_closure: true, // TODO: Make this configurable
// TODO: Make this configurable // TODO: Make this configurable
include_previous_values: true, include_previous_values: true,

View File

@@ -11,7 +11,6 @@ import {
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { import {
buildClanPath,
buildMachinePath, buildMachinePath,
maybeUseMachineName, maybeUseMachineName,
useClanURI, useClanURI,
@@ -25,11 +24,16 @@ 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";
@@ -39,7 +43,6 @@ 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;
@@ -50,43 +53,45 @@ interface ClanContextProps {
isLoading(): boolean; isLoading(): boolean;
isError(): boolean; isError(): boolean;
showAddMachine(): boolean;
setShowAddMachine(value: boolean): void;
} }
function createClanContext( class DefaultClanContext implements ClanContextProps {
clanURI: string, public readonly clanURI: string;
machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult<ClanDetails>,
otherClanQueries: UseQueryResult<ClanDetails>[],
) {
const [showAddMachine, setShowAddMachine] = createSignal(false);
const allClansQueries = [activeClanQuery, ...otherClanQueries];
const allQueries = [machinesQuery, ...allClansQueries];
return { public readonly activeClanQuery: UseQueryResult<ClanDetails>;
clanURI, public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
machinesQuery, public readonly allClansQueries: UseQueryResult<ClanDetails>[];
activeClanQuery,
otherClanQueries,
allClansQueries,
isLoading: () => allQueries.some((q) => q.isLoading),
isError: () => activeClanQuery.isError,
showAddMachine,
setShowAddMachine,
};
}
const ClanContext = createContext<ClanContextProps>(); public readonly machinesQuery: MachinesQueryResult;
export const useClanContext = () => { allQueries: UseQueryResult[];
const ctx = useContext(ClanContext);
if (!ctx) { constructor(
throw new Error("ClanContext not found"); clanURI: string,
machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult<ClanDetails>,
otherClanQueries: UseQueryResult<ClanDetails>[],
) {
this.clanURI = clanURI;
this.machinesQuery = machinesQuery;
this.activeClanQuery = activeClanQuery;
this.otherClanQueries = otherClanQueries;
this.allClansQueries = [activeClanQuery, ...otherClanQueries];
this.allQueries = [machinesQuery, activeClanQuery, ...otherClanQueries];
} }
return ctx;
}; isLoading(): boolean {
return this.allQueries.some((q) => q.isLoading);
}
isError(): boolean {
return this.activeClanQuery.isError;
}
}
export const ClanContext = createContext<ClanContextProps>();
export const Clan: Component<RouteSectionProps> = (props) => { export const Clan: Component<RouteSectionProps> = (props) => {
const clanURI = useClanURI(); const clanURI = useClanURI();
@@ -105,15 +110,17 @@ export const Clan: Component<RouteSectionProps> = (props) => {
const machinesQuery = useMachinesQuery(clanURI); const machinesQuery = useMachinesQuery(clanURI);
const ctx = createClanContext(
clanURI,
machinesQuery,
activeClanQuery,
otherClanQueries,
);
return ( return (
<ClanContext.Provider value={ctx}> <ClanContext.Provider
value={
new DefaultClanContext(
clanURI,
machinesQuery,
activeClanQuery,
otherClanQueries,
)
}
>
<div <div
class={cx(styles.sidebarContainer, { class={cx(styles.sidebarContainer, {
[styles.machineSelected]: useMachineName(), [styles.machineSelected]: useMachineName(),
@@ -127,13 +134,67 @@ 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 = useClanContext(); const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const navigate = useNavigate(); const navigate = useNavigate();
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;
@@ -141,11 +202,45 @@ 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) => {
ctx.setShowAddMachine(true); setShowModal(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
>(); >();
@@ -173,8 +268,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
const selected = ids.values().next().value; const selected = ids.values().next().value;
if (selected) { if (selected) {
navigate(buildMachinePath(ctx.clanURI, selected)); navigate(buildMachinePath(ctx.clanURI, selected));
} else {
navigate(buildClanPath(ctx.clanURI));
} }
}; };
@@ -219,8 +312,9 @@ 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(
@@ -228,7 +322,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
if (mode === "service") { if (mode === "service") {
setShowService(true); setShowService(true);
} else { } else {
// TODO: request soft close instead of forced close // todo: request close instead of force close
setShowService(false); setShowService(false);
} }
}), }),
@@ -239,18 +333,21 @@ const ClanSceneController = (props: RouteSectionProps) => {
<Show when={loadingError()}> <Show when={loadingError()}>
<ListClansModal error={loadingError()} /> <ListClansModal error={loadingError()} />
</Show> </Show>
<Show when={ctx.showAddMachine()}> <Show when={showModal()}>
<AddMachine <MockCreateMachine
onCreated={async (id) => {
const promise = currentPromise();
if (promise) {
await ctx.machinesQuery.refetch();
promise.resolve({ id });
setCurrentPromise(null);
}
}}
onClose={() => { onClose={() => {
ctx.setShowAddMachine(false); setShowModal(false);
currentPromise()?.reject(new Error("User cancelled"));
}}
onSubmit={async (values) => {
try {
const result = await sendCreate(values);
currentPromise()?.resolve(result);
setShowModal(false);
} catch (err) {
currentPromise()?.reject(err);
setShowModal(false);
}
}} }}
/> />
</Show> </Show>
@@ -273,7 +370,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
handleSubmit={handleSubmitService} handleSubmit={handleSubmitService}
onClose={() => { onClose={() => {
setShowService(false); setShowService(false);
setWorldMode("select"); setWorldMode("default");
currentPromise()?.resolve({ id: "0" }); currentPromise()?.resolve({ id: "0" });
}} }}
/> />

View File

@@ -20,8 +20,7 @@ export const Machine = (props: RouteSectionProps) => {
navigateToClan(navigate, clanURI); navigateToClan(navigate, clanURI);
}; };
const sections = () => { const sidebarPane = (machineName: string) => {
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
@@ -52,35 +51,25 @@ export const Machine = (props: RouteSectionProps) => {
const sectionProps = { clanURI, machineName, onSubmit, machineQuery }; const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return ( return (
<> <div class={styles.sidebarPaneContainer}>
<SidebarSectionInstall <SidebarPane
clanURI={clanURI} title={machineName}
machineName={useMachineName()} onClose={onClose}
/> subHeader={() => (
<SectionGeneral {...sectionProps} /> <SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
<SectionTags {...sectionProps} /> )}
</> >
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
<SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} />
</SidebarPane>
</div>
); );
}; };
return ( return (
<Show when={useMachineName()}> <Show when={useMachineName()} keyed>
<div class={styles.sidebarPaneContainer}> {sidebarPane(useMachineName())}
<SidebarPane
title={useMachineName()}
onClose={onClose}
subHeader={
<Show when={useMachineName()} keyed>
<SidebarMachineStatus
clanURI={clanURI}
machineName={useMachineName()}
/>
</Show>
}
>
{sections()}
</SidebarPane>
</div>
</Show> </Show>
); );
}; };

View File

@@ -27,7 +27,6 @@ export class MachineManager {
machinesQueryResult: MachinesQueryResult, machinesQueryResult: MachinesQueryResult,
selectedIds: Accessor<Set<string>>, selectedIds: Accessor<Set<string>>,
setMachinePos: (id: string, position: [number, number] | null) => void, setMachinePos: (id: string, position: [number, number] | null) => void,
camera: THREE.Camera,
) { ) {
this.machinePositionsSignal = machinePositionsSignal; this.machinePositionsSignal = machinePositionsSignal;
@@ -40,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);
} }
} }
@@ -62,11 +61,10 @@ 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 (!Object.keys(positions).includes(id)) { if (!(id in positions)) {
repr.dispose(scene); repr.dispose(scene);
this.machines.delete(id); this.machines.delete(id);
} }
@@ -83,7 +81,6 @@ export class MachineManager {
id, id,
selectedIds, selectedIds,
highlightGroups, highlightGroups,
camera,
); );
this.machines.set(id, repr); this.machines.set(id, repr);
scene.add(repr.group); scene.add(repr.group);

View File

@@ -3,9 +3,6 @@ import { ObjectRegistry } from "./ObjectRegistry";
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js"; import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Accessor, createEffect, createRoot, on } from "solid-js"; import { Accessor, createEffect, createRoot, on } from "solid-js";
import { renderLoop } from "./RenderLoop"; import { renderLoop } from "./RenderLoop";
// @ts-expect-error: No types for troika-three-text
import { Text } from "troika-three-text";
import ttf from "../../.fonts/CommitMonoV143-VF.ttf";
// Constants // Constants
const BASE_SIZE = 0.9; const BASE_SIZE = 0.9;
@@ -31,7 +28,6 @@ export class MachineRepr {
private baseMesh: THREE.Mesh; private baseMesh: THREE.Mesh;
private geometry: THREE.BoxGeometry; private geometry: THREE.BoxGeometry;
private material: THREE.MeshPhongMaterial; private material: THREE.MeshPhongMaterial;
private camera: THREE.Camera;
private disposeRoot: () => void; private disposeRoot: () => void;
@@ -42,10 +38,8 @@ export class MachineRepr {
id: string, id: string,
selectedSignal: Accessor<Set<string>>, selectedSignal: Accessor<Set<string>>,
highlightGroups: Record<string, Set<string>>, // Reactive store highlightGroups: Record<string, Set<string>>, // Reactive store
camera: THREE.Camera,
) { ) {
this.id = id; this.id = id;
this.camera = camera;
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE); this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
this.material = new THREE.MeshPhongMaterial({ this.material = new THREE.MeshPhongMaterial({
color: CUBE_COLOR, color: CUBE_COLOR,
@@ -68,6 +62,7 @@ export class MachineRepr {
this.baseMesh.name = "base"; this.baseMesh.name = "base";
const label = this.createLabel(id); const label = this.createLabel(id);
this.cubeMesh.add(label);
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({ const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
color: BASE_COLOR, // any color you like color: BASE_COLOR, // any color you like
@@ -87,7 +82,6 @@ export class MachineRepr {
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0); shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
this.group = new THREE.Group(); this.group = new THREE.Group();
this.group.add(label);
this.group.add(this.cubeMesh); this.group.add(this.cubeMesh);
this.group.add(this.baseMesh); this.group.add(this.baseMesh);
this.group.add(shadowPlane); this.group.add(shadowPlane);
@@ -167,27 +161,12 @@ export class MachineRepr {
} }
private createLabel(id: string) { private createLabel(id: string) {
const text = new Text(); const div = document.createElement("div");
text.text = id; div.className = "machine-label";
text.font = ttf; div.textContent = id;
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON const label = new CSS2DObject(div);
text.fontSize = 0.15; // relative to your cube size label.position.set(0, CUBE_SIZE + 0.1, 0);
text.color = 0x000000; // any THREE.Color return label;
text.anchorX = "center"; // horizontal centering
text.anchorY = "bottom"; // baseline aligns to cube top
text.position.set(0, CUBE_SIZE + 0.05, 0);
// If you want it to always face camera:
text.userData.isLabel = true;
text.outlineWidth = 0.005;
text.outlineColor = 0x333333;
text.quaternion.copy(this.camera.quaternion);
// Re-render on text changes
text.sync(() => {
renderLoop.requestRender();
});
return text;
} }
dispose(scene: THREE.Scene) { dispose(scene: THREE.Scene) {

View File

@@ -1,7 +1,7 @@
import { Scene, Camera, WebGLRenderer } from "three"; import { Scene, Camera, WebGLRenderer } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js"; import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js"; import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import * as THREE from "three";
/** /**
* Private class to manage the render loop * Private class to manage the render loop
* @internal * @internal
@@ -93,18 +93,6 @@ class RenderLoop {
this.renderer.render(this.bgScene, this.bgCamera); this.renderer.render(this.bgScene, this.bgCamera);
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
this.scene.traverse((obj) => {
if (obj.userData.isLabel) {
(obj as THREE.Mesh).quaternion.copy(this.camera.quaternion);
}
// if (obj.userData.isLabel) {
// const camPos = new THREE.Vector3();
// this.camera.getWorldPosition(camPos);
// obj.lookAt(new THREE.Vector3(camPos.x, obj.position.y, camPos.z));
// }
});
this.labelRenderer.render(this.scene, this.camera); this.labelRenderer.render(this.scene, this.camera);
} }

View File

@@ -1,6 +1,7 @@
.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">

View File

@@ -5,7 +5,6 @@ import {
onMount, onMount,
on, on,
JSX, JSX,
Show,
} from "solid-js"; } from "solid-js";
import "./cubes.css"; import "./cubes.css";
@@ -22,30 +21,6 @@ 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";
import { Portal } from "solid-js/web";
import { Menu } from "../components/ContextMenu/ContextMenu";
import { clearHighlight, setHighlightGroups } from "./highlightStore";
function intersectMachines(
event: MouseEvent,
renderer: THREE.WebGLRenderer,
camera: THREE.Camera,
machineManager: MachineManager,
raycaster: THREE.Raycaster,
): string[] {
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)),
);
return intersects.map((i) => i.object.userData.id);
}
function garbageCollectGroup(group: THREE.Group) { function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) { for (const child of group.children) {
@@ -88,8 +63,8 @@ export function useMachineClick() {
/*Gloabl signal*/ /*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal< const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create" | "move" "default" | "select" | "service" | "create"
>("select"); >("default");
export { worldMode, setWorldMode }; export { worldMode, setWorldMode };
export function CubeScene(props: { export function CubeScene(props: {
@@ -112,7 +87,7 @@ export function CubeScene(props: {
let controls: MapControls; let controls: MapControls;
// Raycaster for clicking // Raycaster for clicking
const raycaster = new THREE.Raycaster(); const raycaster = new THREE.Raycaster();
let actionBase: THREE.Mesh | undefined; let initBase: THREE.Mesh | undefined;
// Create background scene // Create background scene
const bgScene = new THREE.Scene(); const bgScene = new THREE.Scene();
@@ -126,8 +101,6 @@ 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]>();
@@ -135,10 +108,6 @@ export function CubeScene(props: {
position: { x: 0, y: 0, z: 0 }, position: { x: 0, y: 0, z: 0 },
spherical: { radius: 0, theta: 0, phi: 0 }, spherical: { radius: 0, theta: 0, phi: 0 },
}); });
// Context menu state
const [contextOpen, setContextOpen] = createSignal(false);
const [menuPos, setMenuPos] = createSignal<{ x: number; y: number }>();
const [menuIntersection, setMenuIntersection] = createSignal<string[]>([]);
// Grid configuration // Grid configuration
const GRID_SIZE = 1; const GRID_SIZE = 1;
@@ -154,10 +123,8 @@ export function CubeScene(props: {
const BASE_COLOR = 0xecfdff; const BASE_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c; const BASE_EMISSIVE = 0x0c0c0c;
const ACTION_BASE_COLOR = 0x636363; const CREATE_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7; const CREATE_BASE_EMISSIVE = 0xc5fad7;
const MOVE_BASE_EMISSIVE = 0xb2d7ff;
function createCubeBase( function createCubeBase(
cube_pos: [number, number, number], cube_pos: [number, number, number],
@@ -178,6 +145,12 @@ export function CubeScene(props: {
return base; return base;
} }
function toggleSelection(id: string) {
const next = new Set<string>();
next.add(id);
props.onSelect(next);
}
const initialCameraPosition = { x: 20, y: 20, z: 20 }; const initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical(); const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3( initialSphericalCameraPosition.setFromVector3(
@@ -300,13 +273,6 @@ 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);
@@ -374,15 +340,15 @@ export function CubeScene(props: {
); );
// Important create CubeBase depends on sharedBaseGeometry // Important create CubeBase depends on sharedBaseGeometry
actionBase = createCubeBase( initBase = createCubeBase(
[1, BASE_HEIGHT / 2, 1], [1, BASE_HEIGHT / 2, 1],
1, 1,
ACTION_BASE_COLOR, CREATE_BASE_COLOR,
CREATE_BASE_EMISSIVE, CREATE_BASE_EMISSIVE,
); );
actionBase.visible = false; initBase.visible = false;
scene.add(actionBase); scene.add(initBase);
// const spherical = new THREE.Spherical(); // const spherical = new THREE.Spherical();
// spherical.setFromVector3(camera.position); // spherical.setFromVector3(camera.position);
@@ -411,9 +377,9 @@ export function CubeScene(props: {
createEffect( createEffect(
on(worldMode, (mode) => { on(worldMode, (mode) => {
if (mode === "create") { if (mode === "create") {
actionBase!.visible = true; initBase!.visible = true;
} else { } else {
actionBase!.visible = false; initBase!.visible = false;
} }
renderLoop.requestRender(); renderLoop.requestRender();
}), }),
@@ -428,7 +394,6 @@ export function CubeScene(props: {
props.cubesQuery, props.cubesQuery,
props.selectedIds, props.selectedIds,
props.setMachinePos, props.setMachinePos,
camera,
); );
// Click handler: // Click handler:
@@ -451,21 +416,11 @@ export function CubeScene(props: {
console.error("Error creating cube:", error); console.error("Error creating cube:", error);
}) })
.finally(() => { .finally(() => {
if (actionBase) actionBase.visible = false; if (initBase) initBase.visible = false;
setWorldMode("default"); setWorldMode("default");
}); });
} }
if (worldMode() === "move") {
console.log("sanpped");
const currId = menuIntersection().at(0);
const pos = cursorPosition();
if (!currId || !pos) return;
props.setMachinePos(currId, pos);
setWorldMode("select");
clearHighlight("move");
}
const rect = renderer.domElement.getBoundingClientRect(); const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2( const mouse = new THREE.Vector2(
@@ -482,13 +437,13 @@ export function CubeScene(props: {
console.log("Clicked on cube:", intersects); console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id; const id = intersects[0].object.userData.id;
if (worldMode() === "select") props.onSelect(new Set<string>([id])); if (worldMode() === "select") toggleSelection(id);
emitMachineClick(id); // notify subscribers emitMachineClick(id); // notify subscribers
} else { } else {
emitMachineClick(null); emitMachineClick(null);
if (worldMode() === "select") props.onSelect(new Set<string>()); props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
} }
}; };
@@ -519,28 +474,18 @@ export function CubeScene(props: {
renderLoop.requestRender(); renderLoop.requestRender();
}; };
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
const intersection = intersectMachines(
e,
renderer,
camera,
machineManager,
raycaster,
);
if (!intersection.length) return;
setMenuIntersection(intersection);
setMenuPos({ x: e.clientX, y: e.clientY });
setContextOpen(true);
}
};
renderer.domElement.addEventListener("mousedown", handleMouseDown);
renderer.domElement.addEventListener("mousemove", onMouseMove); renderer.domElement.addEventListener("mousemove", onMouseMove);
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
// For debugging,
// TODO: Remove in production
window.addEventListener(
"contextmenu",
(e) => {
e.stopPropagation();
},
{ capture: true },
);
// Initial render // Initial render
renderLoop.requestRender(); renderLoop.requestRender();
@@ -567,12 +512,12 @@ export function CubeScene(props: {
renderer.domElement.removeEventListener("mousemove", onMouseMove); renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
if (actionBase) { if (initBase) {
actionBase.geometry.dispose(); initBase.geometry.dispose();
if (Array.isArray(actionBase.material)) { if (Array.isArray(initBase.material)) {
actionBase.material.forEach((material) => material.dispose()); initBase.material.forEach((material) => material.dispose());
} else { } else {
actionBase.material.dispose(); initBase.material.dispose();
} }
} }
@@ -588,18 +533,10 @@ export function CubeScene(props: {
renderLoop.requestRender(); renderLoop.requestRender();
}; };
const onMouseMove = (event: MouseEvent) => { const onMouseMove = (event: MouseEvent) => {
if (!(worldMode() === "create" || worldMode() === "move")) return; if (worldMode() !== "create") return;
if (!actionBase) return; if (!initBase) return;
console.log("Mouse move in create/move mode"); initBase.visible = true;
actionBase.visible = true;
(actionBase.material as THREE.MeshPhongMaterial).emissive.set(
worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
);
// Calculate mouse position in normalized device coordinates
// (-1 to +1) for both components
const rect = renderer.domElement.getBoundingClientRect(); const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2( const mouse = new THREE.Vector2(
@@ -630,48 +567,22 @@ export function CubeScene(props: {
} }
if ( if (
Math.abs(actionBase.position.x - snapped.x) > 0.01 || Math.abs(initBase.position.x - snapped.x) > 0.01 ||
Math.abs(actionBase.position.z - snapped.z) > 0.01 Math.abs(initBase.position.z - snapped.z) > 0.01
) { ) {
// Only request render if the position actually changed // Only request render if the position actually changed
actionBase.position.set(snapped.x, 0, snapped.z); initBase.position.set(snapped.x, 0, snapped.z);
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
renderLoop.requestRender(); renderLoop.requestRender();
} }
} }
}; };
const handleMenuSelect = (mode: "move") => {
setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) });
console.log("Menu selected, new World mode", worldMode());
};
const machinesQuery = useMachinesQuery(props.clanURI); const machinesQuery = useMachinesQuery(props.clanURI);
return ( return (
<> <>
<Show when={contextOpen()}> <div class="cubes-scene-container" ref={(el) => (container = el)} />
<Portal mount={document.body}>
<Menu
onSelect={handleMenuSelect}
intersect={menuIntersection()}
x={menuPos()!.x - 10}
y={menuPos()!.y - 10}
close={() => setContextOpen(false)}
/>
</Portal>
</Show>
<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}
@@ -681,7 +592,9 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => setWorldMode("select")} onClick={() =>
setWorldMode((v) => (v === "select" ? "default" : "select"))
}
selected={worldMode() === "select"} selected={worldMode() === "select"}
/> />
<ToolbarButton <ToolbarButton
@@ -698,11 +611,11 @@ export function CubeScene(props: {
icon="Services" icon="Services"
selected={worldMode() === "service"} selected={worldMode() === "service"}
onClick={() => { onClick={() => {
setWorldMode("service"); setWorldMode((v) => (v === "service" ? "default" : "service"));
}} }}
/> />
<ToolbarButton <ToolbarButton
icon="Update" icon="Reload"
name="Reload" name="Reload"
description="Reload machines" description="Reload machines"
onClick={() => machinesQuery.refetch()} onClick={() => machinesQuery.refetch()}

View File

@@ -1,119 +0,0 @@
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",
},
};

View File

@@ -1,136 +0,0 @@
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>
);
};

View File

@@ -1,176 +0,0 @@
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>
);
};

View File

@@ -1,76 +0,0 @@
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>
);
};

View File

@@ -1,40 +0,0 @@
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>
);
};

View File

@@ -1,101 +0,0 @@
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);
}
}
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>
);
};

View File

@@ -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 "./InstallMachine"; import { InstallModal } from "./install";
import { import {
createMemoryHistory, createMemoryHistory,
MemoryRouter, MemoryRouter,

View File

@@ -1,5 +1,5 @@
import { defineSteps, useStepper } from "@/src/hooks/stepper"; import { defineSteps, useStepper } from "@/src/hooks/stepper";
import { InstallSteps } from "../InstallMachine"; import { InstallSteps } from "../install";
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";

View File

@@ -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 "../InstallMachine"; import { InstallSteps, InstallStoreType } from "../install";
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";

View File

@@ -11,11 +11,7 @@ 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 { import { InstallSteps, InstallStoreType, PromptValues } from "../install";
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";

View File

@@ -24,42 +24,59 @@ 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", {
modules: [ module: { name: "Borgbackup", input: "clan-core" },
{ info: {
usage_ref: { name: "Borgbackup", input: null }, manifest: {
instance_refs: [], name: "Borgbackup",
native: true, description: "This is module A",
info: { },
manifest: { roles: {
name: "Borgbackup", client: null,
description: "This is module A", server: null,
},
roles: {
client: null,
server: null,
},
}, },
}, },
{ },
usage_ref: { name: "Zerotier", input: "fublub" }, {
instance_refs: [], module: { name: "Zerotier", input: "clan-core" },
native: false, info: {
info: { manifest: {
manifest: { name: "Zerotier",
name: "Zerotier", description: "This is module B",
description: "This is module B", },
}, roles: {
roles: { peer: null,
peer: null, moon: null,
moon: null, controller: 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",

View File

@@ -45,12 +45,15 @@ import {
} from "@/src/scene/highlightStore"; } from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside"; import { useClickOutside } from "@/src/hooks/useClickOutside";
type ModuleItem = ServiceModules["modules"][number]; type ModuleItem = ServiceModules[number];
interface Module { interface Module {
value: string; value: string;
input?: string;
label: string; label: string;
description: string;
raw: ModuleItem; raw: ModuleItem;
instances: string[];
} }
const SelectService = () => { const SelectService = () => {
@@ -65,10 +68,20 @@ const SelectService = () => {
createEffect(() => { createEffect(() => {
if (serviceModulesQuery.data && serviceInstancesQuery.data) { if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions( setModuleOptions(
serviceModulesQuery.data.modules.map((currService) => ({ serviceModulesQuery.data.map((m) => ({
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`, value: `${m.module.name}:${m.module.input}`,
label: currService.usage_ref.name, label: m.module.name,
raw: currService, description: m.info.manifest.description,
input: m.module.input,
raw: m,
// TODO: include the instances that use this module
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),
})), })),
); );
} }
@@ -83,8 +96,8 @@ const SelectService = () => {
if (!module) return; if (!module) return;
set("module", { set("module", {
name: module.raw.usage_ref.name, name: module.raw.module.name,
input: module.raw.usage_ref.input, input: module.raw.module.input,
raw: module.raw, raw: module.raw,
}); });
// TODO: Ideally we need to ask // TODO: Ideally we need to ask
@@ -94,14 +107,14 @@ const SelectService = () => {
// For now: // For now:
// Create a new instance, if there are no instances yet // Create a new instance, if there are no instances yet
// Update the first instance, if there is one // Update the first instance, if there is one
if (module.raw.instance_refs.length === 0) { if (module.instances.length === 0) {
set("action", "create"); set("action", "create");
} else { } else {
if (!serviceInstancesQuery.data) return; if (!serviceInstancesQuery.data) return;
if (!machinesQuery.data) return; if (!machinesQuery.data) return;
set("action", "update"); set("action", "update");
const instanceName = module.raw.instance_refs[0]; const instanceName = module.instances[0];
const instance = serviceInstancesQuery.data[instanceName]; const instance = serviceInstancesQuery.data[instanceName];
console.log("Editing existing instance", module); console.log("Editing existing instance", module);
@@ -151,7 +164,7 @@ const SelectService = () => {
</div> </div>
<div class="flex w-full flex-col"> <div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex gap-1.5"> <Combobox.ItemLabel class="flex gap-1.5">
<Show when={item.raw.instance_refs.length > 0}> <Show when={item.instances.length > 0}>
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5"> <div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
<Typography hierarchy="label" weight="bold" size="xxs"> <Typography hierarchy="label" weight="bold" size="xxs">
Added Added
@@ -170,13 +183,11 @@ const SelectService = () => {
inverted inverted
class="flex justify-between" class="flex justify-between"
> >
<span class="inline-block max-w-80 truncate align-middle">
{item.raw.info.manifest.description}
</span>
<span class="inline-block max-w-32 truncate align-middle"> <span class="inline-block max-w-32 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core"> {item.description}
by {item.raw.usage_ref.input} </span>
</Show> <span class="inline-block max-w-8 truncate align-middle">
by {item.input}
</span> </span>
</Typography> </Typography>
</div> </div>
@@ -527,7 +538,7 @@ export interface InventoryInstance {
name: string; name: string;
module: { module: {
name: string; name: string;
input?: string | null; input?: string;
}; };
roles: Record<string, RoleType>; roles: Record<string, RoleType>;
} }
@@ -540,7 +551,7 @@ interface RoleType {
export interface ServiceStoreType { export interface ServiceStoreType {
module: { module: {
name: string; name: string;
input?: string | null; input: string;
raw?: ModuleItem; raw?: ModuleItem;
}; };
roles: Record<string, TagType[]>; roles: Record<string, TagType[]>;

View File

@@ -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 "@/src/workflows/InstallMachine/InstallMachine"; import { InstallSteps } from "./Install/install";
import styles from "./Steps.module.css"; import styles from "./Steps.module.css";
interface StepLayoutProps { interface StepLayoutProps {

View File

@@ -862,7 +862,7 @@ def test_api_set_prompts(
machine = Machine(name="my_machine", flake=Flake(str(flake.path))) machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
generators = get_generators( generators = get_generators(
machine=machine, machines=[machine],
full_closure=True, full_closure=True,
include_previous_values=True, include_previous_values=True,
) )

View File

@@ -41,7 +41,7 @@ def vars_status(
unfixed_secret_vars = [] unfixed_secret_vars = []
invalid_generators = [] invalid_generators = []
generators = Generator.get_machine_generators(machine.name, machine.flake) generators = Generator.get_machine_generators([machine.name], machine.flake)
if generator_name: if generator_name:
for generator in generators: for generator in generators:
if generator_name == generator.name: if generator_name == generator.name:

View File

@@ -19,7 +19,7 @@ def get_machine_vars(machine: Machine) -> list[Var]:
all_vars = [] all_vars = []
generators = get_generators(machine=machine, full_closure=True) generators = get_generators(machines=[machine], full_closure=True)
for generator in generators: for generator in generators:
for var in generator.files: for var in generator.files:
if var.secret: if var.secret:

View File

@@ -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"Storing var '{var.id}' in a flake is only supported for local flakes: {self.flake}" msg = f"in_flake fact storage 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"

View File

@@ -56,7 +56,7 @@ class SecretStore(StoreBase):
# no need to generate keys if we don't manage secrets # no need to generate keys if we don't manage secrets
from clan_cli.vars.generator import Generator # noqa: PLC0415 from clan_cli.vars.generator import Generator # noqa: PLC0415
vars_generators = Generator.get_machine_generators(machine, self.flake) vars_generators = Generator.get_machine_generators([machine], self.flake)
if not vars_generators: if not vars_generators:
return return
has_secrets = False has_secrets = False
@@ -143,7 +143,7 @@ class SecretStore(StoreBase):
if generators is None: if generators is None:
from clan_cli.vars.generator import Generator # noqa: PLC0415 from clan_cli.vars.generator import Generator # noqa: PLC0415
generators = Generator.get_machine_generators(machine, self.flake) generators = Generator.get_machine_generators([machine], self.flake)
file_found = False file_found = False
outdated = [] outdated = []
for generator in generators: for generator in generators:
@@ -220,7 +220,7 @@ class SecretStore(StoreBase):
def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None: def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None:
from clan_cli.vars.generator import Generator # noqa: PLC0415 from clan_cli.vars.generator import Generator # noqa: PLC0415
vars_generators = Generator.get_machine_generators(machine, self.flake) vars_generators = Generator.get_machine_generators([machine], self.flake)
if "users" in phases or "services" in phases: if "users" in phases or "services" in phases:
key_name = f"{machine}-age.key" key_name = f"{machine}-age.key"
if not has_secret(sops_secrets_folder(self.flake.path) / key_name): if not has_secret(sops_secrets_folder(self.flake.path) / key_name):
@@ -356,7 +356,7 @@ class SecretStore(StoreBase):
if generators is None: if generators is None:
from clan_cli.vars.generator import Generator # noqa: PLC0415 from clan_cli.vars.generator import Generator # noqa: PLC0415
generators = Generator.get_machine_generators(machine, self.flake) generators = Generator.get_machine_generators([machine], self.flake)
file_found = False file_found = False
for generator in generators: for generator in generators:
for file in generator.files: for file in generator.files:

View File

@@ -1,11 +1,11 @@
{ {
inputs.Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU/nixpkgs"; inputs.nixpkgs.follows = "clan-core/nixpkgs";
outputs = outputs =
{ self, Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU, ... }: { self, clan-core, ... }:
let let
clan = Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.lib.clan ({ clan = clan-core.lib.clan ({
inherit self; inherit self;
imports = [ imports = [
./clan.nix ./clan.nix

View File

@@ -1,7 +1,6 @@
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
@@ -26,13 +25,14 @@ log = logging.getLogger(__name__)
BuildOn = Literal["auto", "local", "remote"] BuildOn = Literal["auto", "local", "remote"]
class Step(str, Enum): Step = Literal[
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(Step.GENERATORS) notify_install_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(Step.UPLOAD_SECRETS) notify_install_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,
) )
install_steps = { default_install_steps: dict[str, Step] = {
"kexec": Step.NIXOS_ANYWHERE, "kexec": "nixos-anywhere",
"disko": Step.FORMATTING, "disko": "formatting",
"install": Step.INSTALLING, "install": "installing",
"reboot": Step.REBOOTING, "reboot": "rebooting",
} }
def run_phase(phase: str) -> None: def run_phase(phase: str) -> None:
notification = install_steps.get(phase, Step.NIXOS_ANYWHERE) notification = default_install_steps.get(phase, "nixos-anywhere")
notify_install_step(notification) notify_install_step(notification)
run( run(
[*cmd, "--phases", phase], [*cmd, "--phases", phase],

View File

@@ -13,7 +13,7 @@ class Unknown:
InventoryInstanceModuleNameType = str InventoryInstanceModuleNameType = str
InventoryInstanceModuleInputType = str | None InventoryInstanceModuleInputType = str
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 | None ClanDirectoryType = dict[str, Any] | list[Any] | bool | float | int | str
ClanInventoryType = Inventory ClanInventoryType = Inventory
ClanMachinesType = dict[str, Unknown] ClanMachinesType = dict[str, Unknown]
ClanMetaType = Unknown ClanMetaType = Unknown

View File

@@ -0,0 +1,112 @@
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",
)

View File

@@ -11,7 +11,6 @@ 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
@@ -61,7 +60,7 @@ class ModuleManifest:
raise ValueError(msg) raise ValueError(msg)
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> "ModuleManifest": def from_dict(cls, data: dict) -> "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.
""" """
@@ -148,159 +147,106 @@ def extract_frontmatter[T](
@dataclass @dataclass
class ModuleInfo: class ModuleInfo(TypedDict):
manifest: ModuleManifest manifest: ModuleManifest
roles: dict[str, None] roles: dict[str, None]
@dataclass class Module(TypedDict):
class Module: module: InventoryInstanceModule
# To use this module specify: InventoryInstanceModule :: { input, name } (foreign key)
usage_ref: InventoryInstanceModule
info: ModuleInfo info: ModuleInfo
native: bool
instance_refs: list[str]
@dataclass @API.register
class ClanModules: def list_service_modules(flake: Flake) -> list[Module]:
modules: list[Module] """Show information about a module"""
core_input_name: str modules = flake.select("clanInternals.inventoryClass.modulesPerSource")
res: list[Module] = []
def find_instance_refs_for_module( for input_name, module_set in modules.items():
instances: InventoryInstancesType, for module_name, module_info in module_set.items():
module_ref: InventoryInstanceModule, res.append(
core_input_name: str, Module(
) -> list[str]: module={"name": module_name, "input": input_name},
"""Find all usages of a given module by its module_ref info=ModuleInfo(
manifest=ModuleManifest.from_dict(
If the module is native: module_info.get("manifest"),
module_ref.input := None ),
<instance>.module.name := None roles=module_info.get("roles", {}),
),
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 list_service_modules(flake: Flake) -> ClanModules: def get_service_module(
"""Show information about a module"""
# inputName.moduleName -> ModuleInfo
modules: dict[str, dict[str, Any]] = flake.select(
"clanInternals.inventoryClass.modulesPerSource"
)
# moduleName -> ModuleInfo
builtin_modules: dict[str, Any] = flake.select(
"clanInternals.inventoryClass.staticModules"
)
inventory_store = InventoryStore(flake)
instances = inventory_store.read().get("instances", {})
first_name, first_module = next(iter(builtin_modules.items()))
clan_input_name = None
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 clan_input_name is None:
msg = "Could not determine the clan-core input name"
raise ClanError(msg)
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),
)
)
return ClanModules(res, clan_input_name)
def resolve_service_module_ref(
flake: Flake, flake: Flake,
module_ref: InventoryInstanceModuleType, module_ref: InventoryInstanceModuleType,
) -> Module: ) -> ModuleInfo:
"""Returns the module information for a given module reference
:param module_ref: The module reference to get the information for
:return: Dict of module information
:raises ClanError: If the module_ref is invalid or missing required fields
"""
input_name, module_name = check_service_module_ref(flake, module_ref)
avilable_modules = list_service_modules(flake)
module_set: list[Module] = [
m for m in avilable_modules if m["module"].get("input", None) == input_name
]
if not module_set:
msg = f"Module set for input '{input_name}' not found"
raise ClanError(msg)
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
if module is None:
msg = f"Module '{module_name}' not found in input '{input_name}'"
raise ClanError(msg)
return module["info"]
def check_service_module_ref(
flake: Flake,
module_ref: InventoryInstanceModuleType,
) -> tuple[str, str]:
"""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
""" """
service_modules = list_service_modules(flake) avilable_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)
if input_ref is None or input_ref == service_modules.core_input_name: module_set = [
# Take only the native modules m for m in avilable_modules if m["module"].get("input", None) == input_ref
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 not module_set: if module_set is None:
inputs = {m.usage_ref.get("input") for m in avilable_modules} inputs = {m["module"].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 module return (input_ref, module_name)
@API.register @API.register
@@ -314,16 +260,7 @@ 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 = module_ref.get("input"), module_ref["name"] input_name, module_name = check_service_module_ref(flake, module_ref)
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}",
@@ -337,8 +274,7 @@ def create_service_instance(
roles: InventoryInstanceRolesType, roles: InventoryInstanceRolesType,
) -> None: ) -> None:
"""Show information about a module""" """Show information about a module"""
input_name, module_name = module_ref.get("input"), module_ref["name"] input_name, module_name = check_service_module_ref(flake, module_ref)
module = resolve_service_module_ref(flake, module_ref)
inventory_store = InventoryStore(flake) inventory_store = InventoryStore(flake)
@@ -359,10 +295,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())
allowed_roles = module.info.roles schema = get_service_module_schema(flake, module_ref)
for role_name, role_members in roles.items(): for role_name, role_members in roles.items():
if role_name not in allowed_roles: if role_name not in schema:
msg = f"Role '{role_name}' is not defined in the module" msg = f"Role '{role_name}' is not defined in the module schema"
raise ClanError(msg) raise ClanError(msg)
machine_refs = role_members.get("machines") machine_refs = role_members.get("machines")
@@ -379,21 +315,13 @@ 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
if not input_name: new_instance: InventoryInstance = {
new_instance: InventoryInstance = { "module": {
"module": { "name": module_name,
"name": module_name, "input": input_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(
@@ -403,31 +331,11 @@ def create_service_instance(
) )
@dataclass
class InventoryInstanceInfo:
resolved: Module
module: InventoryInstanceModule
roles: InventoryInstanceRolesType
@API.register @API.register
def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]: def list_service_instances(
"""Returns all currently present service instances including their full configuration""" flake: Flake,
) -> 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

View File

@@ -1,105 +0,0 @@
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({})

View File

@@ -214,12 +214,10 @@ def test_clan_create_api(
store = InventoryStore(clan_dir_flake) store = InventoryStore(clan_dir_flake)
inventory = store.read() inventory = store.read()
service_modules = list_service_modules(clan_dir_flake) modules = list_service_modules(clan_dir_flake)
admin_module = next( admin_module = next(m for m in modules if m["module"]["name"] == "admin")
m for m in service_modules.modules if m.usage_ref.get("name") == "admin" assert admin_module["info"]["manifest"].name == "clan-core/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(
@@ -230,7 +228,7 @@ def test_clan_create_api(
# Invalidate cache because of new inventory # Invalidate cache because of new inventory
clan_dir_flake.invalidate_cache() clan_dir_flake.invalidate_cache()
generators = get_generators(machine=machine, full_closure=True) generators = get_generators(machines=[machine], full_closure=True)
collected_prompt_values = {} collected_prompt_values = {}
for generator in generators: for generator in generators:
prompt_values = {} prompt_values = {}

View File

@@ -218,12 +218,13 @@ 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]:
_field_types = set(field_types) if "None" in field_types or default or default_factory:
if "None" in _field_types or default or default_factory: if "None" in field_types:
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix field_types.remove("None")
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)

View File

@@ -14,8 +14,6 @@ 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;

View File

@@ -1,11 +1,11 @@
{ {
inputs = { inputs = {
clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; clan.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
nixpkgs.follows = "clan-core/nixpkgs"; nixpkgs.follows = "clan/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "clan-core/nixpkgs"; flake-parts.inputs.nixpkgs-lib.follows = "clan/nixpkgs";
}; };
outputs = outputs =