Compare commits
2 Commits
revers-upd
...
pr-5024
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8584f3247a | ||
|
|
d3534a2b72 |
12
devFlake/flake.lock
generated
12
devFlake/flake.lock
generated
@@ -84,11 +84,11 @@
|
||||
},
|
||||
"nixpkgs-dev": {
|
||||
"locked": {
|
||||
"lastModified": 1756662818,
|
||||
"narHash": "sha256-Opggp4xiucQ5gBceZ6OT2vWAZOjQb3qULv39scGZ9Nw=",
|
||||
"lastModified": 1756400612,
|
||||
"narHash": "sha256-0xm2D8u6y1+hCT+o4LCUCm3GCmSJHLAF0jRELyIb1go=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2e6aeede9cb4896693434684bb0002ab2c0cfc09",
|
||||
"rev": "593cac9f894d7d4894e0155bacbbc69e7ef552dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -165,11 +165,11 @@
|
||||
"nixpkgs": []
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756662192,
|
||||
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=",
|
||||
"lastModified": 1755934250,
|
||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4",
|
||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
20
flake.lock
generated
20
flake.lock
generated
@@ -13,11 +13,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756695982,
|
||||
"narHash": "sha256-dyLhOSDzxZtRgi5aj/OuaZJUsuvo+8sZ9CU/qieZ15c=",
|
||||
"rev": "cc8f26e7e6c2dc985526ba59b286ae5a83168cdb",
|
||||
"lastModified": 1756091210,
|
||||
"narHash": "sha256-oEUEAZnLbNHi8ti4jY8x10yWcIkYoFc5XD+2hjmOS04=",
|
||||
"rev": "eb831bca21476fa8f6df26cb39e076842634700d",
|
||||
"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": {
|
||||
"type": "tarball",
|
||||
@@ -99,11 +99,11 @@
|
||||
},
|
||||
"nixos-facter-modules": {
|
||||
"locked": {
|
||||
"lastModified": 1756491981,
|
||||
"narHash": "sha256-lXyDAWPw/UngVtQfgQ8/nrubs2r+waGEYIba5UX62+k=",
|
||||
"lastModified": 1756291602,
|
||||
"narHash": "sha256-FYhiArSzcx60OwoH3JBp5Ho1D5HEwmZx6WoquauDv3g=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-facter-modules",
|
||||
"rev": "c1b29520945d3e148cd96618c8a0d1f850965d8c",
|
||||
"rev": "5c37cee817c94f50710ab11c25de572bc3604bd5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -181,11 +181,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756662192,
|
||||
"narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=",
|
||||
"lastModified": 1755934250,
|
||||
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4",
|
||||
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -245,8 +245,6 @@ in
|
||||
in
|
||||
{ config, ... }:
|
||||
{
|
||||
staticModules = clan-core.clan.modules;
|
||||
|
||||
distributedServices = clanLib.inventory.mapInstances {
|
||||
inherit (clanConfig) inventory exportsModule;
|
||||
inherit flakeInputs directory;
|
||||
|
||||
@@ -23,12 +23,6 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
options.staticModules = lib.mkOption {
|
||||
readOnly = true;
|
||||
type = lib.types.raw;
|
||||
|
||||
apply = moduleSet: lib.mapAttrs (inspectModule "<clan-core>") moduleSet;
|
||||
};
|
||||
options.modulesPerSource = lib.mkOption {
|
||||
# { sourceName :: { moduleName :: {} }}
|
||||
readOnly = true;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
lib.mkIf config.clan.core.enableRecommendedDefaults {
|
||||
|
||||
# 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:
|
||||
# - systems with raids as this currently require manual configuration: https://github.com/NixOS/nixpkgs/issues/210210
|
||||
|
||||
@@ -12,14 +12,8 @@ let
|
||||
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
|
||||
&& (builtins.tryEval kernelPackages).success
|
||||
&& (
|
||||
let
|
||||
zfsPackage =
|
||||
if isUnstable then
|
||||
kernelPackages.zfs_unstable
|
||||
else
|
||||
kernelPackages.${pkgs.zfs.kernelModuleAttribute};
|
||||
in
|
||||
!(zfsPackage.meta.broken or false)
|
||||
(!isUnstable && !kernelPackages.zfs.meta.broken)
|
||||
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
|
||||
)
|
||||
) pkgs.linuxKernel.packages;
|
||||
latestKernelPackage = lib.last (
|
||||
@@ -30,5 +24,5 @@ let
|
||||
in
|
||||
{
|
||||
# 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;
|
||||
}
|
||||
|
||||
@@ -48,10 +48,6 @@ let
|
||||
url = "https://github.com/eigilnikolajsen/commit-mono/raw/0b3b192f035cdc8d1ea8ffb5463cc23d73d0b89f/src/fonts/fontlab/CommitMonoV143-VF.woff2";
|
||||
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
|
||||
runCommand "" { } ''
|
||||
@@ -66,5 +62,4 @@ runCommand "" { } ''
|
||||
cp ${archivoSemi.semiBold} $out/ArchivoSemiCondensed-SemiBold.woff2
|
||||
|
||||
cp ${commitMono} $out/CommitMonoV143-VF.woff2
|
||||
cp ${commitMono_ttf} $out/CommitMonoV143-VF.ttf
|
||||
''
|
||||
|
||||
55
pkgs/clan-app/ui/package-lock.json
generated
55
pkgs/clan-app/ui/package-lock.json
generated
@@ -23,7 +23,6 @@
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-toast": "^0.5.0",
|
||||
"three": "^0.176.0",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"valibot": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3808,15 +3807,6 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -7538,15 +7528,6 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -8674,36 +8655,6 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
@@ -9317,12 +9268,6 @@
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-toast": "^0.5.0",
|
||||
"three": "^0.176.0",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"valibot": "^1.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,12 @@ import { A } from "@solidjs/router";
|
||||
import { Accordion } from "@kobalte/core/accordion";
|
||||
import Icon from "../Icon/Icon";
|
||||
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 { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||
import { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import { SidebarProps } from "./Sidebar";
|
||||
import { Button } from "../Button/Button";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
||||
|
||||
interface MachineProps {
|
||||
clanURI: string;
|
||||
@@ -59,7 +58,10 @@ const MachineRoute = (props: MachineProps) => {
|
||||
export const SidebarBody = (props: SidebarProps) => {
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const ctx = useClanContext();
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
}
|
||||
|
||||
const sectionLabels = (props.staticSections || []).map(
|
||||
(section) => section.title,
|
||||
@@ -69,15 +71,6 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
// we want them all to be open by default
|
||||
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 (
|
||||
<div class="sidebar-body">
|
||||
<Accordion
|
||||
@@ -107,42 +100,18 @@ export const SidebarBody = (props: SidebarProps) => {
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="content">
|
||||
<Show
|
||||
when={machines()}
|
||||
fallback={
|
||||
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
inverted
|
||||
>
|
||||
No machines yet
|
||||
</Typography>
|
||||
<Button
|
||||
hierarchy="primary"
|
||||
size="s"
|
||||
startIcon="Machine"
|
||||
onClick={() => ctx.setShowAddMachine(true)}
|
||||
>
|
||||
Add machine
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<nav>
|
||||
<For each={Object.entries(machines()!)}>
|
||||
{([id, machine]) => (
|
||||
<MachineRoute
|
||||
clanURI={clanURI}
|
||||
machineID={id}
|
||||
name={machine.name || id}
|
||||
serviceCount={0}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</Show>
|
||||
<nav>
|
||||
<For each={Object.entries(ctx.machinesQuery.data || {})}>
|
||||
{([id, machine]) => (
|
||||
<MachineRoute
|
||||
clanURI={clanURI}
|
||||
machineID={id}
|
||||
name={machine.name || id}
|
||||
serviceCount={0}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import Icon from "@/src/components/Icon/Icon";
|
||||
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
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 { setActiveClanURI } from "@/src/stores/clan";
|
||||
import { Button } from "../Button/Button";
|
||||
import { ClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { ClanSettingsModal } from "@/src/modals/ClanSettingsModal/ClanSettingsModal";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
|
||||
export const SidebarHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -17,7 +17,11 @@ export const SidebarHeader = () => {
|
||||
const [showSettings, setShowSettings] = createSignal(false);
|
||||
|
||||
// get information about the current active clan
|
||||
const ctx = useClanContext();
|
||||
const ctx = useContext(ClanContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("SidebarContext not found");
|
||||
}
|
||||
|
||||
const clanChar = () =>
|
||||
ctx?.activeClanQuery?.data?.details.name.charAt(0).toUpperCase();
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface SidebarPaneProps {
|
||||
class?: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
subHeader?: JSX.Element;
|
||||
subHeader?: () => JSX.Element;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const SidebarPane = (props: SidebarPaneProps) => {
|
||||
</KButton>
|
||||
</div>
|
||||
<Show when={props.subHeader}>
|
||||
<div class="sub-header">{props.subHeader}</div>
|
||||
<div class="sub-header">{props.subHeader!()}</div>
|
||||
</Show>
|
||||
<div class="body">{props.children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
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 { useMachineStateQuery } from "@/src/hooks/queries";
|
||||
import styles from "./SidebarSectionInstall.module.css";
|
||||
|
||||
@@ -143,7 +143,6 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineState>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||
staleTime: 60_000, // 1 minute stale time
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("get_machine_state", {
|
||||
machine: {
|
||||
@@ -456,12 +455,14 @@ export const useMachineGenerators = (
|
||||
],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_generators", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
machines: [
|
||||
{
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanUri,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
full_closure: true, // TODO: Make this configurable
|
||||
// TODO: Make this configurable
|
||||
include_previous_values: true,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import {
|
||||
buildClanPath,
|
||||
buildMachinePath,
|
||||
maybeUseMachineName,
|
||||
useClanURI,
|
||||
@@ -25,11 +24,16 @@ import {
|
||||
useClanListQuery,
|
||||
useMachinesQuery,
|
||||
} from "@/src/hooks/queries";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Splash } from "@/src/scene/splash";
|
||||
import cx from "classnames";
|
||||
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 { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
@@ -39,7 +43,6 @@ import {
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
@@ -50,43 +53,45 @@ interface ClanContextProps {
|
||||
|
||||
isLoading(): boolean;
|
||||
isError(): boolean;
|
||||
|
||||
showAddMachine(): boolean;
|
||||
setShowAddMachine(value: boolean): void;
|
||||
}
|
||||
|
||||
function createClanContext(
|
||||
clanURI: string,
|
||||
machinesQuery: MachinesQueryResult,
|
||||
activeClanQuery: UseQueryResult<ClanDetails>,
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[],
|
||||
) {
|
||||
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
||||
const allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||
const allQueries = [machinesQuery, ...allClansQueries];
|
||||
class DefaultClanContext implements ClanContextProps {
|
||||
public readonly clanURI: string;
|
||||
|
||||
return {
|
||||
clanURI,
|
||||
machinesQuery,
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
allClansQueries,
|
||||
isLoading: () => allQueries.some((q) => q.isLoading),
|
||||
isError: () => activeClanQuery.isError,
|
||||
showAddMachine,
|
||||
setShowAddMachine,
|
||||
};
|
||||
}
|
||||
public readonly activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
public readonly otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
public readonly allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
|
||||
const ClanContext = createContext<ClanContextProps>();
|
||||
public readonly machinesQuery: MachinesQueryResult;
|
||||
|
||||
export const useClanContext = () => {
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
allQueries: UseQueryResult[];
|
||||
|
||||
constructor(
|
||||
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) => {
|
||||
const clanURI = useClanURI();
|
||||
@@ -105,15 +110,17 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
|
||||
const ctx = createClanContext(
|
||||
clanURI,
|
||||
machinesQuery,
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
);
|
||||
|
||||
return (
|
||||
<ClanContext.Provider value={ctx}>
|
||||
<ClanContext.Provider
|
||||
value={
|
||||
new DefaultClanContext(
|
||||
clanURI,
|
||||
machinesQuery,
|
||||
activeClanQuery,
|
||||
otherClanQueries,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={cx(styles.sidebarContainer, {
|
||||
[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 ctx = useClanContext();
|
||||
const ctx = useContext(ClanContext);
|
||||
if (!ctx) {
|
||||
throw new Error("ClanContext not found");
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [showService, setShowService] = createSignal(false);
|
||||
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||
resolve: ({ id }: { id: string }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
@@ -141,11 +202,45 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const onCreate = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ctx.setShowAddMachine(true);
|
||||
setShowModal(true);
|
||||
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<
|
||||
{ title: string; description: string } | undefined
|
||||
>();
|
||||
@@ -173,8 +268,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const selected = ids.values().next().value;
|
||||
if (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);
|
||||
}
|
||||
toast.success("Created");
|
||||
//
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
setShowService(false);
|
||||
setWorldMode("select");
|
||||
};
|
||||
|
||||
createEffect(
|
||||
@@ -228,7 +322,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
if (mode === "service") {
|
||||
setShowService(true);
|
||||
} else {
|
||||
// TODO: request soft close instead of forced close
|
||||
// todo: request close instead of force close
|
||||
setShowService(false);
|
||||
}
|
||||
}),
|
||||
@@ -239,18 +333,21 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
<Show when={loadingError()}>
|
||||
<ListClansModal error={loadingError()} />
|
||||
</Show>
|
||||
<Show when={ctx.showAddMachine()}>
|
||||
<AddMachine
|
||||
onCreated={async (id) => {
|
||||
const promise = currentPromise();
|
||||
if (promise) {
|
||||
await ctx.machinesQuery.refetch();
|
||||
promise.resolve({ id });
|
||||
setCurrentPromise(null);
|
||||
}
|
||||
}}
|
||||
<Show when={showModal()}>
|
||||
<MockCreateMachine
|
||||
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>
|
||||
@@ -273,7 +370,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
setShowService(false);
|
||||
setWorldMode("select");
|
||||
setWorldMode("default");
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -20,8 +20,7 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
navigateToClan(navigate, clanURI);
|
||||
};
|
||||
|
||||
const sections = () => {
|
||||
const machineName = useMachineName();
|
||||
const sidebarPane = (machineName: string) => {
|
||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||
|
||||
// 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 };
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarSectionInstall
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
</>
|
||||
<div class={styles.sidebarPaneContainer}>
|
||||
<SidebarPane
|
||||
title={machineName}
|
||||
onClose={onClose}
|
||||
subHeader={() => (
|
||||
<SidebarMachineStatus clanURI={clanURI} machineName={machineName} />
|
||||
)}
|
||||
>
|
||||
<SidebarSectionInstall clanURI={clanURI} machineName={machineName} />
|
||||
<SectionGeneral {...sectionProps} />
|
||||
<SectionTags {...sectionProps} />
|
||||
</SidebarPane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={useMachineName()}>
|
||||
<div class={styles.sidebarPaneContainer}>
|
||||
<SidebarPane
|
||||
title={useMachineName()}
|
||||
onClose={onClose}
|
||||
subHeader={
|
||||
<Show when={useMachineName()} keyed>
|
||||
<SidebarMachineStatus
|
||||
clanURI={clanURI}
|
||||
machineName={useMachineName()}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{sections()}
|
||||
</SidebarPane>
|
||||
</div>
|
||||
<Show when={useMachineName()} keyed>
|
||||
{sidebarPane(useMachineName())}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ export class MachineManager {
|
||||
machinesQueryResult: MachinesQueryResult,
|
||||
selectedIds: Accessor<Set<string>>,
|
||||
setMachinePos: (id: string, position: [number, number] | null) => void,
|
||||
camera: THREE.Camera,
|
||||
) {
|
||||
this.machinePositionsSignal = machinePositionsSignal;
|
||||
|
||||
@@ -40,10 +39,10 @@ export class MachineManager {
|
||||
|
||||
const actualIds = Object.keys(machinesQueryResult.data);
|
||||
const machinePositions = machinePositionsSignal();
|
||||
|
||||
// Remove stale
|
||||
for (const id of Object.keys(machinePositions)) {
|
||||
if (!actualIds.includes(id)) {
|
||||
console.log("Removing stale machine", id);
|
||||
setMachinePos(id, null);
|
||||
}
|
||||
}
|
||||
@@ -62,11 +61,10 @@ export class MachineManager {
|
||||
//
|
||||
createEffect(() => {
|
||||
const positions = machinePositionsSignal();
|
||||
if (!positions) return;
|
||||
|
||||
// Remove machines from scene
|
||||
for (const [id, repr] of this.machines) {
|
||||
if (!Object.keys(positions).includes(id)) {
|
||||
if (!(id in positions)) {
|
||||
repr.dispose(scene);
|
||||
this.machines.delete(id);
|
||||
}
|
||||
@@ -83,7 +81,6 @@ export class MachineManager {
|
||||
id,
|
||||
selectedIds,
|
||||
highlightGroups,
|
||||
camera,
|
||||
);
|
||||
this.machines.set(id, repr);
|
||||
scene.add(repr.group);
|
||||
|
||||
@@ -3,9 +3,6 @@ import { ObjectRegistry } from "./ObjectRegistry";
|
||||
import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
||||
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
|
||||
const BASE_SIZE = 0.9;
|
||||
@@ -31,7 +28,6 @@ export class MachineRepr {
|
||||
private baseMesh: THREE.Mesh;
|
||||
private geometry: THREE.BoxGeometry;
|
||||
private material: THREE.MeshPhongMaterial;
|
||||
private camera: THREE.Camera;
|
||||
|
||||
private disposeRoot: () => void;
|
||||
|
||||
@@ -42,10 +38,8 @@ export class MachineRepr {
|
||||
id: string,
|
||||
selectedSignal: Accessor<Set<string>>,
|
||||
highlightGroups: Record<string, Set<string>>, // Reactive store
|
||||
camera: THREE.Camera,
|
||||
) {
|
||||
this.id = id;
|
||||
this.camera = camera;
|
||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||
this.material = new THREE.MeshPhongMaterial({
|
||||
color: CUBE_COLOR,
|
||||
@@ -68,6 +62,7 @@ export class MachineRepr {
|
||||
this.baseMesh.name = "base";
|
||||
|
||||
const label = this.createLabel(id);
|
||||
this.cubeMesh.add(label);
|
||||
|
||||
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
||||
color: BASE_COLOR, // any color you like
|
||||
@@ -87,7 +82,6 @@ export class MachineRepr {
|
||||
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
|
||||
|
||||
this.group = new THREE.Group();
|
||||
this.group.add(label);
|
||||
this.group.add(this.cubeMesh);
|
||||
this.group.add(this.baseMesh);
|
||||
this.group.add(shadowPlane);
|
||||
@@ -167,27 +161,12 @@ export class MachineRepr {
|
||||
}
|
||||
|
||||
private createLabel(id: string) {
|
||||
const text = new Text();
|
||||
text.text = id;
|
||||
text.font = ttf;
|
||||
// text.font = ".fonts/CommitMonoV143-VF.woff2"; // <-- normal web font, not JSON
|
||||
text.fontSize = 0.15; // relative to your cube size
|
||||
text.color = 0x000000; // any THREE.Color
|
||||
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;
|
||||
const div = document.createElement("div");
|
||||
div.className = "machine-label";
|
||||
div.textContent = id;
|
||||
const label = new CSS2DObject(div);
|
||||
label.position.set(0, CUBE_SIZE + 0.1, 0);
|
||||
return label;
|
||||
}
|
||||
|
||||
dispose(scene: THREE.Scene) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Scene, Camera, WebGLRenderer } from "three";
|
||||
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
|
||||
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
/**
|
||||
* Private class to manage the render loop
|
||||
* @internal
|
||||
@@ -93,18 +93,6 @@ class RenderLoop {
|
||||
|
||||
this.renderer.render(this.bgScene, this.bgCamera);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.cubes-scene-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
onMount,
|
||||
on,
|
||||
JSX,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import "./cubes.css";
|
||||
|
||||
@@ -22,30 +21,6 @@ import { Accessor } from "solid-js";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
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) {
|
||||
for (const child of group.children) {
|
||||
@@ -88,8 +63,8 @@ export function useMachineClick() {
|
||||
|
||||
/*Gloabl signal*/
|
||||
const [worldMode, setWorldMode] = createSignal<
|
||||
"default" | "select" | "service" | "create" | "move"
|
||||
>("select");
|
||||
"default" | "select" | "service" | "create"
|
||||
>("default");
|
||||
export { worldMode, setWorldMode };
|
||||
|
||||
export function CubeScene(props: {
|
||||
@@ -112,7 +87,7 @@ export function CubeScene(props: {
|
||||
let controls: MapControls;
|
||||
// Raycaster for clicking
|
||||
const raycaster = new THREE.Raycaster();
|
||||
let actionBase: THREE.Mesh | undefined;
|
||||
let initBase: THREE.Mesh | undefined;
|
||||
|
||||
// Create background scene
|
||||
const bgScene = new THREE.Scene();
|
||||
@@ -126,8 +101,6 @@ export function CubeScene(props: {
|
||||
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
|
||||
"grid",
|
||||
);
|
||||
// Managed by controls
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
|
||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||
|
||||
@@ -135,10 +108,6 @@ export function CubeScene(props: {
|
||||
position: { x: 0, y: 0, z: 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
|
||||
const GRID_SIZE = 1;
|
||||
@@ -154,10 +123,8 @@ export function CubeScene(props: {
|
||||
const BASE_COLOR = 0xecfdff;
|
||||
const BASE_EMISSIVE = 0x0c0c0c;
|
||||
|
||||
const ACTION_BASE_COLOR = 0x636363;
|
||||
|
||||
const CREATE_BASE_COLOR = 0x636363;
|
||||
const CREATE_BASE_EMISSIVE = 0xc5fad7;
|
||||
const MOVE_BASE_EMISSIVE = 0xb2d7ff;
|
||||
|
||||
function createCubeBase(
|
||||
cube_pos: [number, number, number],
|
||||
@@ -178,6 +145,12 @@ export function CubeScene(props: {
|
||||
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 initialSphericalCameraPosition = new THREE.Spherical();
|
||||
initialSphericalCameraPosition.setFromVector3(
|
||||
@@ -300,13 +273,6 @@ export function CubeScene(props: {
|
||||
bgCamera,
|
||||
);
|
||||
|
||||
controls.addEventListener("start", (e) => {
|
||||
setIsDragging(true);
|
||||
});
|
||||
controls.addEventListener("end", (e) => {
|
||||
setIsDragging(false);
|
||||
});
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
|
||||
scene.add(ambientLight);
|
||||
@@ -374,15 +340,15 @@ export function CubeScene(props: {
|
||||
);
|
||||
|
||||
// Important create CubeBase depends on sharedBaseGeometry
|
||||
actionBase = createCubeBase(
|
||||
initBase = createCubeBase(
|
||||
[1, BASE_HEIGHT / 2, 1],
|
||||
1,
|
||||
ACTION_BASE_COLOR,
|
||||
CREATE_BASE_COLOR,
|
||||
CREATE_BASE_EMISSIVE,
|
||||
);
|
||||
actionBase.visible = false;
|
||||
initBase.visible = false;
|
||||
|
||||
scene.add(actionBase);
|
||||
scene.add(initBase);
|
||||
|
||||
// const spherical = new THREE.Spherical();
|
||||
// spherical.setFromVector3(camera.position);
|
||||
@@ -411,9 +377,9 @@ export function CubeScene(props: {
|
||||
createEffect(
|
||||
on(worldMode, (mode) => {
|
||||
if (mode === "create") {
|
||||
actionBase!.visible = true;
|
||||
initBase!.visible = true;
|
||||
} else {
|
||||
actionBase!.visible = false;
|
||||
initBase!.visible = false;
|
||||
}
|
||||
renderLoop.requestRender();
|
||||
}),
|
||||
@@ -428,7 +394,6 @@ export function CubeScene(props: {
|
||||
props.cubesQuery,
|
||||
props.selectedIds,
|
||||
props.setMachinePos,
|
||||
camera,
|
||||
);
|
||||
|
||||
// Click handler:
|
||||
@@ -451,21 +416,11 @@ export function CubeScene(props: {
|
||||
console.error("Error creating cube:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (actionBase) actionBase.visible = false;
|
||||
if (initBase) initBase.visible = false;
|
||||
|
||||
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 mouse = new THREE.Vector2(
|
||||
@@ -482,13 +437,13 @@ export function CubeScene(props: {
|
||||
console.log("Clicked on cube:", intersects);
|
||||
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
|
||||
} else {
|
||||
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();
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
// For debugging,
|
||||
// TODO: Remove in production
|
||||
window.addEventListener(
|
||||
"contextmenu",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
// Initial render
|
||||
renderLoop.requestRender();
|
||||
@@ -567,12 +512,12 @@ export function CubeScene(props: {
|
||||
renderer.domElement.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
|
||||
if (actionBase) {
|
||||
actionBase.geometry.dispose();
|
||||
if (Array.isArray(actionBase.material)) {
|
||||
actionBase.material.forEach((material) => material.dispose());
|
||||
if (initBase) {
|
||||
initBase.geometry.dispose();
|
||||
if (Array.isArray(initBase.material)) {
|
||||
initBase.material.forEach((material) => material.dispose());
|
||||
} else {
|
||||
actionBase.material.dispose();
|
||||
initBase.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,18 +533,10 @@ export function CubeScene(props: {
|
||||
renderLoop.requestRender();
|
||||
};
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!(worldMode() === "create" || worldMode() === "move")) return;
|
||||
if (!actionBase) return;
|
||||
if (worldMode() !== "create") return;
|
||||
if (!initBase) return;
|
||||
|
||||
console.log("Mouse move in create/move mode");
|
||||
|
||||
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
|
||||
initBase.visible = true;
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
@@ -630,48 +567,22 @@ export function CubeScene(props: {
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(actionBase.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(actionBase.position.z - snapped.z) > 0.01
|
||||
Math.abs(initBase.position.x - snapped.x) > 0.01 ||
|
||||
Math.abs(initBase.position.z - snapped.z) > 0.01
|
||||
) {
|
||||
// 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
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={contextOpen()}>
|
||||
<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="cubes-scene-container" ref={(el) => (container = el)} />
|
||||
<div class="toolbar-container">
|
||||
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||
{props.toolbarPopup}
|
||||
@@ -681,7 +592,9 @@ export function CubeScene(props: {
|
||||
description="Select machine"
|
||||
name="Select"
|
||||
icon="Cursor"
|
||||
onClick={() => setWorldMode("select")}
|
||||
onClick={() =>
|
||||
setWorldMode((v) => (v === "select" ? "default" : "select"))
|
||||
}
|
||||
selected={worldMode() === "select"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
@@ -698,11 +611,11 @@ export function CubeScene(props: {
|
||||
icon="Services"
|
||||
selected={worldMode() === "service"}
|
||||
onClick={() => {
|
||||
setWorldMode("service");
|
||||
setWorldMode((v) => (v === "service" ? "default" : "service"));
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="Update"
|
||||
icon="Reload"
|
||||
name="Reload"
|
||||
description="Reload machines"
|
||||
onClick={() => machinesQuery.refetch()}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||
import { InstallModal } from "./InstallMachine";
|
||||
import { InstallModal } from "./install";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
MemoryRouter,
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
||||
import { InstallSteps } from "../InstallMachine";
|
||||
import { InstallSteps } from "../install";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { StepLayout } from "../../Steps";
|
||||
import { NavSection } from "@/src/components/NavSection/NavSection";
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
valiForm,
|
||||
} from "@modular-forms/solid";
|
||||
import * as v from "valibot";
|
||||
import { InstallSteps, InstallStoreType } from "../InstallMachine";
|
||||
import { InstallSteps, InstallStoreType } from "../install";
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||
import { Select } from "@/src/components/Select/Select";
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import * as v from "valibot";
|
||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||
import {
|
||||
InstallSteps,
|
||||
InstallStoreType,
|
||||
PromptValues,
|
||||
} from "../InstallMachine";
|
||||
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { Alert } from "@/src/components/Alert/Alert";
|
||||
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
||||
@@ -24,42 +24,59 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
): ApiCall<K> => {
|
||||
// TODO: Make this configurable for every story
|
||||
const resultData: Partial<ResultDataMap> = {
|
||||
list_service_modules: {
|
||||
core_input_name: "clan-core",
|
||||
modules: [
|
||||
{
|
||||
usage_ref: { name: "Borgbackup", input: null },
|
||||
instance_refs: [],
|
||||
native: true,
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Borgbackup",
|
||||
description: "This is module A",
|
||||
},
|
||||
roles: {
|
||||
client: null,
|
||||
server: null,
|
||||
},
|
||||
list_service_modules: [
|
||||
{
|
||||
module: { name: "Borgbackup", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Borgbackup",
|
||||
description: "This is module A",
|
||||
},
|
||||
roles: {
|
||||
client: null,
|
||||
server: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
usage_ref: { name: "Zerotier", input: "fublub" },
|
||||
instance_refs: [],
|
||||
native: false,
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Zerotier",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
peer: null,
|
||||
moon: null,
|
||||
controller: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Zerotier", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Zerotier",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
peer: null,
|
||||
moon: null,
|
||||
controller: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Admin", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Admin",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Garage", input: "lo-l" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Garage",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
list_machines: {
|
||||
jon: {
|
||||
name: "jon",
|
||||
|
||||
@@ -45,12 +45,15 @@ import {
|
||||
} from "@/src/scene/highlightStore";
|
||||
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
||||
|
||||
type ModuleItem = ServiceModules["modules"][number];
|
||||
type ModuleItem = ServiceModules[number];
|
||||
|
||||
interface Module {
|
||||
value: string;
|
||||
input?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
raw: ModuleItem;
|
||||
instances: string[];
|
||||
}
|
||||
|
||||
const SelectService = () => {
|
||||
@@ -65,10 +68,20 @@ const SelectService = () => {
|
||||
createEffect(() => {
|
||||
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
||||
setModuleOptions(
|
||||
serviceModulesQuery.data.modules.map((currService) => ({
|
||||
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
|
||||
label: currService.usage_ref.name,
|
||||
raw: currService,
|
||||
serviceModulesQuery.data.map((m) => ({
|
||||
value: `${m.module.name}:${m.module.input}`,
|
||||
label: m.module.name,
|
||||
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;
|
||||
|
||||
set("module", {
|
||||
name: module.raw.usage_ref.name,
|
||||
input: module.raw.usage_ref.input,
|
||||
name: module.raw.module.name,
|
||||
input: module.raw.module.input,
|
||||
raw: module.raw,
|
||||
});
|
||||
// TODO: Ideally we need to ask
|
||||
@@ -94,14 +107,14 @@ const SelectService = () => {
|
||||
// For now:
|
||||
// Create a new instance, if there are no instances yet
|
||||
// Update the first instance, if there is one
|
||||
if (module.raw.instance_refs.length === 0) {
|
||||
if (module.instances.length === 0) {
|
||||
set("action", "create");
|
||||
} else {
|
||||
if (!serviceInstancesQuery.data) return;
|
||||
if (!machinesQuery.data) return;
|
||||
set("action", "update");
|
||||
|
||||
const instanceName = module.raw.instance_refs[0];
|
||||
const instanceName = module.instances[0];
|
||||
const instance = serviceInstancesQuery.data[instanceName];
|
||||
console.log("Editing existing instance", module);
|
||||
|
||||
@@ -151,7 +164,7 @@ const SelectService = () => {
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<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">
|
||||
<Typography hierarchy="label" weight="bold" size="xxs">
|
||||
Added
|
||||
@@ -170,13 +183,11 @@ const SelectService = () => {
|
||||
inverted
|
||||
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">
|
||||
<Show when={!item.raw.native} fallback="by clan-core">
|
||||
by {item.raw.usage_ref.input}
|
||||
</Show>
|
||||
{item.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-8 truncate align-middle">
|
||||
by {item.input}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
@@ -527,7 +538,7 @@ export interface InventoryInstance {
|
||||
name: string;
|
||||
module: {
|
||||
name: string;
|
||||
input?: string | null;
|
||||
input?: string;
|
||||
};
|
||||
roles: Record<string, RoleType>;
|
||||
}
|
||||
@@ -540,7 +551,7 @@ interface RoleType {
|
||||
export interface ServiceStoreType {
|
||||
module: {
|
||||
name: string;
|
||||
input?: string | null;
|
||||
input: string;
|
||||
raw?: ModuleItem;
|
||||
};
|
||||
roles: Record<string, TagType[]>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { JSX } from "solid-js";
|
||||
import { useStepper } from "../hooks/stepper";
|
||||
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";
|
||||
|
||||
interface StepLayoutProps {
|
||||
|
||||
@@ -862,7 +862,7 @@ def test_api_set_prompts(
|
||||
|
||||
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
|
||||
generators = get_generators(
|
||||
machine=machine,
|
||||
machines=[machine],
|
||||
full_closure=True,
|
||||
include_previous_values=True,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ def vars_status(
|
||||
unfixed_secret_vars = []
|
||||
invalid_generators = []
|
||||
|
||||
generators = Generator.get_machine_generators(machine.name, machine.flake)
|
||||
generators = Generator.get_machine_generators([machine.name], machine.flake)
|
||||
if generator_name:
|
||||
for generator in generators:
|
||||
if generator_name == generator.name:
|
||||
|
||||
@@ -19,7 +19,7 @@ def get_machine_vars(machine: Machine) -> list[Var]:
|
||||
|
||||
all_vars = []
|
||||
|
||||
generators = get_generators(machine=machine, full_closure=True)
|
||||
generators = get_generators(machines=[machine], full_closure=True)
|
||||
for generator in generators:
|
||||
for var in generator.files:
|
||||
if var.secret:
|
||||
|
||||
@@ -29,7 +29,7 @@ class FactStore(StoreBase):
|
||||
value: bytes,
|
||||
) -> Path | None:
|
||||
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)
|
||||
folder = self.directory(generator, var.name)
|
||||
file_path = folder / "value"
|
||||
|
||||
@@ -56,7 +56,7 @@ class SecretStore(StoreBase):
|
||||
# no need to generate keys if we don't manage secrets
|
||||
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:
|
||||
return
|
||||
has_secrets = False
|
||||
@@ -143,7 +143,7 @@ class SecretStore(StoreBase):
|
||||
if generators is None:
|
||||
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
|
||||
outdated = []
|
||||
for generator in generators:
|
||||
@@ -220,7 +220,7 @@ class SecretStore(StoreBase):
|
||||
def populate_dir(self, machine: str, output_dir: Path, phases: list[str]) -> None:
|
||||
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:
|
||||
key_name = f"{machine}-age.key"
|
||||
if not has_secret(sops_secrets_folder(self.flake.path) / key_name):
|
||||
@@ -356,7 +356,7 @@ class SecretStore(StoreBase):
|
||||
if generators is None:
|
||||
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
|
||||
for generator in generators:
|
||||
for file in generator.files:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
inputs.Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||
inputs.nixpkgs.follows = "Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU/nixpkgs";
|
||||
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||
inputs.nixpkgs.follows = "clan-core/nixpkgs";
|
||||
|
||||
outputs =
|
||||
{ self, Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU, ... }:
|
||||
{ self, clan-core, ... }:
|
||||
let
|
||||
clan = Y2xhbi1jaW9yZS1uZXZlci1kZXBlbmQtb24tbWU.lib.clan ({
|
||||
clan = clan-core.lib.clan ({
|
||||
inherit self;
|
||||
imports = [
|
||||
./clan.nix
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from time import time
|
||||
@@ -26,13 +25,14 @@ log = logging.getLogger(__name__)
|
||||
BuildOn = Literal["auto", "local", "remote"]
|
||||
|
||||
|
||||
class Step(str, Enum):
|
||||
GENERATORS = "generators"
|
||||
UPLOAD_SECRETS = "upload-secrets"
|
||||
NIXOS_ANYWHERE = "nixos-anywhere"
|
||||
FORMATTING = "formatting"
|
||||
REBOOTING = "rebooting"
|
||||
INSTALLING = "installing"
|
||||
Step = Literal[
|
||||
"generators",
|
||||
"upload-secrets",
|
||||
"nixos-anywhere",
|
||||
"formatting",
|
||||
"rebooting",
|
||||
"installing",
|
||||
]
|
||||
|
||||
|
||||
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_install_step(Step.GENERATORS)
|
||||
notify_install_step("generators")
|
||||
generate_facts([machine])
|
||||
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)
|
||||
|
||||
# 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_vars_store.populate_dir(
|
||||
machine.name,
|
||||
@@ -214,15 +214,15 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
|
||||
cmd,
|
||||
)
|
||||
|
||||
install_steps = {
|
||||
"kexec": Step.NIXOS_ANYWHERE,
|
||||
"disko": Step.FORMATTING,
|
||||
"install": Step.INSTALLING,
|
||||
"reboot": Step.REBOOTING,
|
||||
default_install_steps: dict[str, Step] = {
|
||||
"kexec": "nixos-anywhere",
|
||||
"disko": "formatting",
|
||||
"install": "installing",
|
||||
"reboot": "rebooting",
|
||||
}
|
||||
|
||||
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)
|
||||
run(
|
||||
[*cmd, "--phases", phase],
|
||||
|
||||
@@ -13,7 +13,7 @@ class Unknown:
|
||||
|
||||
|
||||
InventoryInstanceModuleNameType = str
|
||||
InventoryInstanceModuleInputType = str | None
|
||||
InventoryInstanceModuleInputType = str
|
||||
|
||||
class InventoryInstanceModule(TypedDict):
|
||||
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
|
||||
ClanMachinesType = dict[str, Unknown]
|
||||
ClanMetaType = Unknown
|
||||
|
||||
112
pkgs/clan-cli/clan_lib/services/instances.py
Normal file
112
pkgs/clan-cli/clan_lib/services/instances.py
Normal 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",
|
||||
)
|
||||
@@ -11,7 +11,6 @@ from clan_lib.nix_models.clan import (
|
||||
InventoryInstanceModule,
|
||||
InventoryInstanceModuleType,
|
||||
InventoryInstanceRolesType,
|
||||
InventoryInstancesType,
|
||||
)
|
||||
from clan_lib.persist.inventory_store import InventoryStore
|
||||
from clan_lib.persist.util import set_value_by_path
|
||||
@@ -61,7 +60,7 @@ class ModuleManifest:
|
||||
raise ValueError(msg)
|
||||
|
||||
@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.
|
||||
Drops any keys that are not defined in the dataclass.
|
||||
"""
|
||||
@@ -148,159 +147,106 @@ def extract_frontmatter[T](
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleInfo:
|
||||
class ModuleInfo(TypedDict):
|
||||
manifest: ModuleManifest
|
||||
roles: dict[str, None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Module:
|
||||
# To use this module specify: InventoryInstanceModule :: { input, name } (foreign key)
|
||||
usage_ref: InventoryInstanceModule
|
||||
class Module(TypedDict):
|
||||
module: InventoryInstanceModule
|
||||
info: ModuleInfo
|
||||
native: bool
|
||||
instance_refs: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClanModules:
|
||||
modules: list[Module]
|
||||
core_input_name: str
|
||||
@API.register
|
||||
def list_service_modules(flake: Flake) -> list[Module]:
|
||||
"""Show information about a module"""
|
||||
modules = flake.select("clanInternals.inventoryClass.modulesPerSource")
|
||||
|
||||
|
||||
def find_instance_refs_for_module(
|
||||
instances: InventoryInstancesType,
|
||||
module_ref: InventoryInstanceModule,
|
||||
core_input_name: str,
|
||||
) -> list[str]:
|
||||
"""Find all usages of a given module by its module_ref
|
||||
|
||||
If the module is native:
|
||||
module_ref.input := None
|
||||
<instance>.module.name := None
|
||||
|
||||
If module is from explicit input
|
||||
<instance>.module.name != None
|
||||
module_ref.input could be None, if explicit input refers to a native module
|
||||
|
||||
"""
|
||||
res: list[str] = []
|
||||
for instance_name, instance in instances.items():
|
||||
local_ref = instance.get("module")
|
||||
if not local_ref:
|
||||
continue
|
||||
|
||||
local_name: str = local_ref.get("name", instance_name)
|
||||
local_input: str | None = local_ref.get("input")
|
||||
|
||||
# Normal match
|
||||
if (
|
||||
local_name == module_ref.get("name")
|
||||
and local_input == module_ref.get("input")
|
||||
) or (local_input == core_input_name and local_name == module_ref.get("name")):
|
||||
res.append(instance_name)
|
||||
res: list[Module] = []
|
||||
for input_name, module_set in modules.items():
|
||||
for module_name, module_info in module_set.items():
|
||||
res.append(
|
||||
Module(
|
||||
module={"name": module_name, "input": input_name},
|
||||
info=ModuleInfo(
|
||||
manifest=ModuleManifest.from_dict(
|
||||
module_info.get("manifest"),
|
||||
),
|
||||
roles=module_info.get("roles", {}),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@API.register
|
||||
def list_service_modules(flake: Flake) -> ClanModules:
|
||||
"""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(
|
||||
def get_service_module(
|
||||
flake: Flake,
|
||||
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
|
||||
|
||||
:param module_ref: The module reference to check
|
||||
:raises ClanError: If the module_ref is invalid or missing required fields
|
||||
"""
|
||||
service_modules = list_service_modules(flake)
|
||||
avilable_modules = service_modules.modules
|
||||
avilable_modules = list_service_modules(flake)
|
||||
|
||||
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:
|
||||
# Take only the native modules
|
||||
module_set = [m for m in avilable_modules if m.native]
|
||||
else:
|
||||
# Match the input ref
|
||||
module_set = [
|
||||
m for m in avilable_modules if m.usage_ref.get("input", None) == input_ref
|
||||
]
|
||||
module_set = [
|
||||
m for m in avilable_modules if m["module"].get("input", None) == input_ref
|
||||
]
|
||||
|
||||
if not module_set:
|
||||
inputs = {m.usage_ref.get("input") for m in avilable_modules}
|
||||
if module_set is None:
|
||||
inputs = {m["module"].get("input") for m in avilable_modules}
|
||||
msg = f"module set for input '{input_ref}' not found"
|
||||
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)
|
||||
|
||||
module_name = module_ref.get("name")
|
||||
if not module_name:
|
||||
msg = "Module name is required in module_ref"
|
||||
raise ClanError(msg)
|
||||
|
||||
module = next((m for m in module_set if m.usage_ref["name"] == module_name), None)
|
||||
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
|
||||
if module is None:
|
||||
msg = f"module with name '{module_name}' not found"
|
||||
raise ClanError(msg)
|
||||
|
||||
return module
|
||||
return (input_ref, module_name)
|
||||
|
||||
|
||||
@API.register
|
||||
@@ -314,16 +260,7 @@ def get_service_module_schema(
|
||||
:return: Dict of schemas for the service module roles
|
||||
:raises ClanError: If the module_ref is invalid or missing required fields
|
||||
"""
|
||||
input_name, module_name = module_ref.get("input"), module_ref["name"]
|
||||
module = resolve_service_module_ref(flake, module_ref)
|
||||
|
||||
if module is None:
|
||||
msg = f"Module '{module_name}' not found in input '{input_name}'"
|
||||
raise ClanError(msg)
|
||||
|
||||
if input_name is None:
|
||||
msg = "Not implemented for: input_name is None"
|
||||
raise ClanError(msg)
|
||||
input_name, module_name = check_service_module_ref(flake, module_ref)
|
||||
|
||||
return flake.select(
|
||||
f"clanInternals.inventoryClass.moduleSchemas.{input_name}.{module_name}",
|
||||
@@ -337,8 +274,7 @@ def create_service_instance(
|
||||
roles: InventoryInstanceRolesType,
|
||||
) -> None:
|
||||
"""Show information about a module"""
|
||||
input_name, module_name = module_ref.get("input"), module_ref["name"]
|
||||
module = resolve_service_module_ref(flake, module_ref)
|
||||
input_name, module_name = check_service_module_ref(flake, module_ref)
|
||||
|
||||
inventory_store = InventoryStore(flake)
|
||||
|
||||
@@ -359,10 +295,10 @@ def create_service_instance(
|
||||
all_machines = inventory.get("machines", {})
|
||||
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():
|
||||
if role_name not in allowed_roles:
|
||||
msg = f"Role '{role_name}' is not defined in the module"
|
||||
if role_name not in schema:
|
||||
msg = f"Role '{role_name}' is not defined in the module schema"
|
||||
raise ClanError(msg)
|
||||
|
||||
machine_refs = role_members.get("machines")
|
||||
@@ -379,21 +315,13 @@ def create_service_instance(
|
||||
# settings = role_members.get("settings", {})
|
||||
|
||||
# Create a new instance with the given roles
|
||||
if not input_name:
|
||||
new_instance: InventoryInstance = {
|
||||
"module": {
|
||||
"name": module_name,
|
||||
},
|
||||
"roles": roles,
|
||||
}
|
||||
else:
|
||||
new_instance = {
|
||||
"module": {
|
||||
"name": module_name,
|
||||
"input": input_name,
|
||||
},
|
||||
"roles": roles,
|
||||
}
|
||||
new_instance: InventoryInstance = {
|
||||
"module": {
|
||||
"name": module_name,
|
||||
"input": input_name,
|
||||
},
|
||||
"roles": roles,
|
||||
}
|
||||
|
||||
set_value_by_path(inventory, f"instances.{instance_name}", new_instance)
|
||||
inventory_store.write(
|
||||
@@ -403,31 +331,11 @@ def create_service_instance(
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InventoryInstanceInfo:
|
||||
resolved: Module
|
||||
module: InventoryInstanceModule
|
||||
roles: InventoryInstanceRolesType
|
||||
|
||||
|
||||
@API.register
|
||||
def list_service_instances(flake: Flake) -> dict[str, InventoryInstanceInfo]:
|
||||
"""Returns all currently present service instances including their full configuration"""
|
||||
def list_service_instances(
|
||||
flake: Flake,
|
||||
) -> dict[str, InventoryInstance]:
|
||||
"""Show information about a module"""
|
||||
inventory_store = InventoryStore(flake)
|
||||
inventory = inventory_store.read()
|
||||
|
||||
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
|
||||
return inventory.get("instances", {})
|
||||
|
||||
@@ -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({})
|
||||
@@ -214,12 +214,10 @@ def test_clan_create_api(
|
||||
store = InventoryStore(clan_dir_flake)
|
||||
inventory = store.read()
|
||||
|
||||
service_modules = list_service_modules(clan_dir_flake)
|
||||
modules = list_service_modules(clan_dir_flake)
|
||||
|
||||
admin_module = next(
|
||||
m for m in service_modules.modules if m.usage_ref.get("name") == "admin"
|
||||
)
|
||||
assert admin_module.info.manifest.name == "clan-core/admin"
|
||||
admin_module = next(m for m in modules if m["module"]["name"] == "admin")
|
||||
assert admin_module["info"]["manifest"].name == "clan-core/admin"
|
||||
|
||||
set_value_by_path(inventory, "instances", inventory_conf.instances)
|
||||
store.write(
|
||||
@@ -230,7 +228,7 @@ def test_clan_create_api(
|
||||
# Invalidate cache because of new inventory
|
||||
clan_dir_flake.invalidate_cache()
|
||||
|
||||
generators = get_generators(machine=machine, full_closure=True)
|
||||
generators = get_generators(machines=[machine], full_closure=True)
|
||||
collected_prompt_values = {}
|
||||
for generator in generators:
|
||||
prompt_values = {}
|
||||
|
||||
@@ -218,12 +218,13 @@ def get_field_def(
|
||||
default_factory: str | None = None,
|
||||
type_appendix: str = "",
|
||||
) -> tuple[str, str]:
|
||||
_field_types = set(field_types)
|
||||
if "None" in _field_types or default or default_factory:
|
||||
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix
|
||||
if "None" in field_types or default or default_factory:
|
||||
if "None" in field_types:
|
||||
field_types.remove("None")
|
||||
serialised_types = " | ".join(sort_types(field_types)) + type_appendix
|
||||
serialised_types = f"{serialised_types}"
|
||||
else:
|
||||
serialised_types = " | ".join(sort_types(_field_types)) + type_appendix
|
||||
serialised_types = " | ".join(sort_types(field_types)) + type_appendix
|
||||
|
||||
return (field_name, serialised_types)
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ let
|
||||
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;
|
||||
nixpkgs.pkgs = self.inputs.nixpkgs.legacyPackages.x86_64-linux;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
|
||||
inputs = {
|
||||
clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||
nixpkgs.follows = "clan-core/nixpkgs";
|
||||
clan.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
|
||||
nixpkgs.follows = "clan/nixpkgs";
|
||||
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user