Compare commits

..

1 Commits

Author SHA1 Message Date
Qubasa
e60c3d984d clan-cli: Refactor the API to use the Flake object 2025-05-07 15:03:37 +02:00
41 changed files with 3281 additions and 3695 deletions

View File

@@ -14,7 +14,7 @@ in
./installation/flake-module.nix
./morph/flake-module.nix
./nixos-documentation/flake-module.nix
./dont-depend-on-repo-root.nix
./sanity-checks/dont-depend-on-repo-root.nix
];
perSystem =
{

View File

@@ -105,7 +105,10 @@ in
private_key = {
inherit owner;
};
public_key.secret = false;
public_key = {
inherit owner;
secret = false;
};
};
runtimeInputs = [
@@ -131,7 +134,10 @@ in
private_key = {
inherit owner;
};
public_key.secret = false;
public_key = {
inherit owner;
secret = false;
};
};
runtimeInputs = [

View File

@@ -10,6 +10,9 @@ let
};
in
{
clan.inventory.modules = {
hello-world = module;
};
clan.modules = {
hello-world = module;
};

20
flake.lock generated
View File

@@ -16,11 +16,11 @@
]
},
"locked": {
"lastModified": 1746628829,
"narHash": "sha256-q77HWbHlJTXFVRD2kRnYwqGfbGmCm9XYPG0ZgLLVh8M=",
"rev": "9a3856421bd6733192b57037cdf26712d73c0871",
"lastModified": 1746459034,
"narHash": "sha256-VHHc8EFPu2uk8mf4ItTHwxgrQxFixNHkclPQMXZfYig=",
"rev": "d63db1621463918966e8e0ec2eb7ddbe8aae332e",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/9a3856421bd6733192b57037cdf26712d73c0871.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/d63db1621463918966e8e0ec2eb7ddbe8aae332e.tar.gz"
},
"original": {
"type": "tarball",
@@ -34,11 +34,11 @@
]
},
"locked": {
"lastModified": 1746695594,
"narHash": "sha256-pAAWYs3S+/tY65vemHZdVSXpeIz4JINEJZoPoBjr8JU=",
"lastModified": 1746411114,
"narHash": "sha256-mLlkVX1kKbAa/Ns5u26wDYw4YW4ziMFM21fhtRmfirU=",
"owner": "nix-community",
"repo": "disko",
"rev": "6bb82b77ce140137177e30df067759931ab60a73",
"rev": "b5d1320ebc2f34dbea4655f95167f55e2130cdb3",
"type": "github"
},
"original": {
@@ -74,11 +74,11 @@
]
},
"locked": {
"lastModified": 1746708654,
"narHash": "sha256-GeC99gu5H6+AjBXsn5dOhP4/ApuioGCBkufdmEIWPRs=",
"lastModified": 1746254942,
"narHash": "sha256-Y062AuRx6l+TJNX8wxZcT59SSLsqD9EedAY0mqgTtQE=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "6cb36e8327421c61e5a3bbd08ed63491b616364a",
"rev": "760a11c87009155afa0140d55c40e7c336d62d7a",
"type": "github"
},
"original": {

View File

@@ -45,7 +45,7 @@ let
inherit inventory directory;
flakeInputs = config.self.inputs;
prefix = config._prefix ++ [ "inventoryClass" ];
localModuleSet = config.modules;
localModuleSet = config.self.clan.modules;
}
);
@@ -179,7 +179,6 @@ in
# Merge the meta attributes from the buildClan function
{
inventory.modules = clan-core.clanModules;
inventory._legacyModules = clan-core.clanModules;
}
# config.inventory.meta <- config.meta
{ inventory.meta = config.meta; }

View File

@@ -96,12 +96,6 @@ in
./assertions.nix
];
options = {
_legacyModules = lib.mkOption {
internal = true;
visible = false;
default = { };
};
options = lib.mkOption {
internal = true;
visible = false;
@@ -144,28 +138,6 @@ in
};
```
'';
apply =
moduleSet:
let
allowedNames = lib.attrNames config._legacyModules;
in
if builtins.all (moduleName: builtins.elem moduleName allowedNames) (lib.attrNames moduleSet) then
moduleSet
else
lib.warn ''
`inventory.modules` will be deprecated soon.
Please migrate the following modules into `clan.service` modules
and register them in `clan.modules`
${lib.concatStringsSep "\n" (
map (m: "'${m}'") (lib.attrNames (lib.filterAttrs (n: _v: !builtins.elem n allowedNames) moduleSet))
)}
See: https://docs.clan.lol/manual/distributed-services/
And: https://docs.clan.lol/authoring/clanServices/
'' moduleSet;
};
assertions = lib.mkOption {

View File

@@ -2,11 +2,9 @@ import argparse
import ctypes
import os
import re
import shutil
import subprocess
import time
import types
import uuid
from collections.abc import Callable
from contextlib import _GeneratorContextManager
from dataclasses import dataclass
@@ -15,8 +13,6 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from colorama import Fore, Style
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
# Load the C library
@@ -248,7 +244,7 @@ class Machine:
"""
# Always run command with shell opts
command = f"set -eo pipefail; source /etc/profile; set -xu; {command}"
command = f"set -eo pipefail; source /etc/profile; set -u; {command}"
proc = subprocess.run(
self.nsenter_command(command),
@@ -472,42 +468,8 @@ class Driver:
print(f"Starting {machine.name}")
machine.start()
# Print copy-pastable nsenter command to debug container tests
for machine in self.machines:
nspawn_uuid = uuid.uuid4()
# We lauch a sleep here, so we can pgrep the process cmdline for
# the uuid
sleep = shutil.which("sleep")
assert sleep is not None, "sleep command not found"
machine.execute(
f"systemd-run /bin/sh -c '{sleep} 999999999 && echo {nspawn_uuid}'",
)
print(f"nsenter for {machine.name}:")
print(
" ".join(
[
Style.BRIGHT,
Fore.CYAN,
"sudo",
"nsenter",
"--user",
"--target",
f"$(\\pgrep -f '^/bin/sh.*{nspawn_uuid}')",
"--mount",
"--uts",
"--ipc",
"--net",
"--pid",
"--cgroup",
"/bin/sh",
"-c",
"bash",
Style.RESET_ALL,
]
)
)
print(" ".join(machine.nsenter_command("bash")))
def test_symbols(self) -> dict[str, Any]:
general_symbols = {

View File

@@ -1,21 +0,0 @@
{ lib, ... }:
{
_class = "clan.service";
manifest.name = "test";
roles.peer.interface =
{ ... }:
{
options.debug = lib.mkOption { default = 1; };
};
roles.peer.perInstance =
{ settings, ... }:
{
nixosModule = {
options.debug = lib.mkOption {
default = settings;
};
};
};
}

View File

@@ -58,25 +58,7 @@ in
)
)
''
The config.clan.core.vars.generators.${generator.name}.files.${file.name} is not secret:
${lib.optionalString
(file.owner != "root")
''
The owner is set to ${file.owner}, but should be root.
''
}
${lib.optionalString
(file.group != (if _class == "darwin" then "wheel" else "root"))
''
The group is set to ${file.group}, but should be ${if _class == "darwin" then "wheel" else "root"}.
''
}
${lib.optionalString
(file.mode != "0400")
''
The mode is set to ${file.mode}, but should be 0400.
''
}
The config.clan.core.vars.generators.${generator.name}.files.${file.name} is not secret, but has non-default owner/group/mode set.
This doesn't work because the file will be added to the nix store
''
) [ ] (lib.attrValues generator.files)

View File

@@ -575,12 +575,12 @@ class Flake:
identifier: str
inputs_from: str | None = None
hash: str | None = None
flake_cache_path: Path | None = None
store_path: str | None = None
_flake_cache_path: Path | None = field(init=False, default=None)
_cache: FlakeCache | None = field(init=False, default=None)
_path: Path | None = field(init=False, default=None)
_is_local: bool | None = field(init=False, default=None)
cache: FlakeCache | None = None
_cache: FlakeCache | None = None
_path: Path | None = None
_is_local: bool | None = None
@classmethod
def from_json(cls: type["Flake"], data: dict[str, Any]) -> "Flake":

View File

@@ -27,8 +27,10 @@ def test_list_modules(test_flake_with_core: FlakeForTest) -> None:
base_path = test_flake_with_core.path
modules_info = list_modules(str(base_path))
assert "localModules" in modules_info
assert "modulesPerSource" in modules_info
assert len(modules_info.items()) > 1
# Random test for those two modules
assert "borgbackup" in modules_info
assert "syncthing" in modules_info
@pytest.mark.impure

View File

@@ -12,12 +12,7 @@ from clan_cli.tests.age_keys import SopsSetup
from clan_cli.tests.fixtures_flakes import ClanFlake
from clan_cli.tests.helpers import cli
from clan_cli.vars.check import check_vars
from clan_cli.vars.generate import (
Generator,
generate_vars_for_machine,
generate_vars_for_machine_interactive,
get_generators_closure,
)
from clan_cli.vars.generate import Generator, generate_vars_for_machine_interactive
from clan_cli.vars.get import get_var
from clan_cli.vars.graph import all_missing_closure, requested_closure
from clan_cli.vars.list import stringify_all_vars
@@ -645,6 +640,9 @@ def test_api_set_prompts(
monkeypatch: pytest.MonkeyPatch,
flake: ClanFlake,
) -> None:
from clan_cli.vars._types import GeneratorUpdate
from clan_cli.vars.list import get_generators, set_prompts
config = flake.machines["my_machine"]
config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
@@ -654,39 +652,33 @@ def test_api_set_prompts(
flake.refresh()
monkeypatch.chdir(flake.path)
params = {"machine_name": "my_machine", "base_dir": str(flake.path)}
generate_vars_for_machine(
machine_name="my_machine",
base_dir=flake.path,
generators=["my_generator"],
all_prompt_values={
"my_generator": {
"prompt1": "input1",
}
},
set_prompts(
**params,
updates=[
GeneratorUpdate(
generator="my_generator",
prompt_values={"prompt1": "input1"},
)
],
)
machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
store = in_repo.FactStore(machine)
assert store.exists(Generator("my_generator"), "prompt1")
assert store.get(Generator("my_generator"), "prompt1").decode() == "input1"
generate_vars_for_machine(
machine_name="my_machine",
base_dir=flake.path,
generators=["my_generator"],
all_prompt_values={
"my_generator": {
"prompt1": "input2",
}
},
set_prompts(
**params,
updates=[
GeneratorUpdate(
generator="my_generator",
prompt_values={"prompt1": "input2"},
)
],
)
assert store.get(Generator("my_generator"), "prompt1").decode() == "input2"
generators = get_generators_closure(
machine_name="my_machine",
base_dir=flake.path,
regenerate=True,
include_previous_values=True,
)
generators = get_generators(**params)
assert len(generators) == 1
assert generators[0].name == "my_generator"
assert generators[0].prompts[0].name == "prompt1"

View File

@@ -294,28 +294,10 @@ def _ask_prompts(
return prompt_values
def _get_previous_value(
machine: "Machine",
generator: Generator,
prompt: Prompt,
) -> str | None:
if not prompt.persist:
return None
pub_store = machine.public_vars_store
if pub_store.exists(generator, prompt.name):
return pub_store.get(generator, prompt.name).decode()
sec_store = machine.secret_vars_store
if sec_store.exists(generator, prompt.name):
return sec_store.get(generator, prompt.name).decode()
return None
def get_closure(
machine: "Machine",
generator_name: str | None,
regenerate: bool,
include_previous_values: bool = False,
) -> list[Generator]:
from .graph import all_missing_closure, full_closure
@@ -328,24 +310,14 @@ def get_closure(
for generator in vars_generators:
generator.machine(machine)
result_closure = []
if generator_name is None: # all generators selected
if regenerate:
result_closure = full_closure(generators)
else:
result_closure = all_missing_closure(generators)
return full_closure(generators)
return all_missing_closure(generators)
# specific generator selected
elif regenerate:
result_closure = requested_closure([generator_name], generators)
else:
result_closure = minimal_closure([generator_name], generators)
if include_previous_values:
for generator in result_closure:
for prompt in generator.prompts:
prompt.previous_value = _get_previous_value(machine, generator, prompt)
return result_closure
if regenerate:
return requested_closure([generator_name], generators)
return minimal_closure([generator_name], generators)
@API.register
@@ -353,7 +325,6 @@ def get_generators_closure(
machine_name: str,
base_dir: Path,
regenerate: bool = False,
include_previous_values: bool = False,
) -> list[Generator]:
from clan_cli.machines.machines import Machine
@@ -361,14 +332,13 @@ def get_generators_closure(
machine=Machine(name=machine_name, flake=Flake(str(base_dir))),
generator_name=None,
regenerate=regenerate,
include_previous_values=include_previous_values,
)
def _generate_vars_for_machine(
machine: "Machine",
generators: list[Generator],
all_prompt_values: dict[str, dict[str, str]],
all_prompt_values: dict[str, dict],
no_sandbox: bool = False,
) -> bool:
for generator in generators:
@@ -380,7 +350,7 @@ def _generate_vars_for_machine(
generator=generator,
secret_vars_store=machine.secret_vars_store,
public_vars_store=machine.public_vars_store,
prompt_values=all_prompt_values.get(generator.name, {}),
prompt_values=all_prompt_values[generator.name],
no_sandbox=no_sandbox,
)
return True

View File

@@ -1,11 +1,13 @@
import json
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TypedDict
from clan_cli.errors import ClanError
from clan_cli.flake import Flake
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.nix import nix_eval
from . import API
@@ -141,50 +143,53 @@ def get_roles(module_path: Path) -> None | list[str]:
]
class ModuleManifest(TypedDict):
name: str
features: dict[str, bool]
@dataclass
class ModuleInfo:
manifest: ModuleManifest
roles: dict[str, None]
description: str
readme: str
categories: list[str]
roles: list[str] | None
features: list[str] = field(default_factory=list)
constraints: dict[str, Any] = field(default_factory=dict)
class ModuleLists(TypedDict):
modulesPerSource: dict[str, dict[str, ModuleInfo]]
localModules: dict[str, ModuleInfo]
@API.register
def list_modules(base_path: str) -> ModuleLists:
"""
Show information about a module
"""
flake = Flake(base_path)
modules = flake.select(
"clanInternals.inventoryClass.{?modulesPerSource,?localModules}"
def get_modules(base_path: str) -> dict[str, str]:
cmd = nix_eval(
[
f"{base_path}#clanInternals.inventory.modules",
"--json",
]
)
print("Modules found:", modules)
try:
proc = run_no_stdout(cmd)
res = proc.stdout.strip()
except ClanCmdError as e:
msg = "clanInternals might not have inventory.modules attributes"
raise ClanError(
msg,
location=f"list_modules {base_path}",
description="Evaluation failed on clanInternals.inventory.modules attribute",
) from e
modules: dict[str, str] = json.loads(res)
return modules
@dataclass
class LegacyModuleInfo:
description: str
categories: list[str]
roles: None | list[str]
readme: str
features: list[str]
constraints: dict[str, Any]
@API.register
def list_modules(base_path: str) -> dict[str, ModuleInfo]:
"""
Show information about a module
"""
modules = get_modules(base_path)
return {
module_name: get_module_info(module_name, Path(module_path))
for module_name, module_path in modules.items()
}
def get_module_info(
module_name: str,
module_path: Path,
) -> LegacyModuleInfo:
) -> ModuleInfo:
"""
Retrieves information about a module
"""
@@ -209,7 +214,7 @@ def get_module_info(
readme, f"{module_path}/README.md"
)
return LegacyModuleInfo(
return ModuleInfo(
description=frontmatter.description,
categories=frontmatter.categories,
roles=get_roles(module_path),

File diff suppressed because it is too large Load Diff

View File

@@ -142,87 +142,91 @@ export function SelectInput(props: SelectInputpProps) {
</InputLabel>
}
field={
<InputBase
error={!!props.error}
disabled={props.disabled}
required={props.required}
class="!justify-start"
divRef={setReference}
inputElem={
<button
// TODO: Keyboard acessibililty
// Currently the popover only opens with onClick
// Options are not selectable with keyboard
tabIndex={-1}
disabled={props.disabled}
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
type="button"
class="flex w-full items-center gap-2"
formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
<Show
when={props.adornment && props.adornment.position === "start"}
<>
<InputBase
error={!!props.error}
disabled={props.disabled}
required={props.required}
class="!justify-start"
divRef={setReference}
inputElem={
<button
// TODO: Keyboard acessibililty
// Currently the popover only opens with onClick
// Options are not selectable with keyboard
tabIndex={-1}
disabled={props.disabled}
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
type="button"
class="flex w-full items-center gap-2"
formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
{props.adornment?.content}
</Show>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
props.adornment && props.adornment.position === "start"
}
fallback={props.placeholder}
>
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
{props.adornment?.content}
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
</button>
}
/>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
}
fallback={props.placeholder}
>
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon icon="CaretDown" class="ml-auto mr-2"></Icon>
</button>
}
/>
</>
}
/>

View File

@@ -18,7 +18,7 @@ export const FieldLayout = (props: LayoutProps) => {
class={cx("grid grid-cols-10 items-center", intern.class)}
{...divProps}
>
<div class="col-span-5 flex items-center">{props.label}</div>
<label class="col-span-5">{props.label}</label>
<div class="col-span-5">{props.field}</div>
{props.error && <span class="col-span-full">{props.error}</span>}
</div>

View File

@@ -96,7 +96,6 @@ export const DynForm = (props: FormProps) => {
return (
<>
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
{props.components?.before}
<SchemaFields

View File

@@ -98,6 +98,7 @@ export async function set_single_service<T extends keyof Services>(
inventory.services = inventory.services || {};
inventory.services[service_name] = inventory.services[service_name] || {};
// @ts-expect-error: This doesn't check
inventory.services[service_name][instance_key] = service_config;
console.log("saving inventory", inventory);
return callApi("set_inventory", {

View File

@@ -0,0 +1,224 @@
import { createSignal, For, Setter, Show } from "solid-js";
import { callApi, SuccessQuery } from "../api";
import { Menu } from "./Menu";
import { activeURI } from "../App";
import toast from "solid-toast";
import { A, useNavigate } from "@solidjs/router";
import { RndThumbnail } from "./noiseThumbnail";
import Icon from "./icon";
import { Filter } from "../routes/machines";
import { Typography } from "./Typography";
import { Button } from "./button";
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info?: MachineDetails;
nixOnly?: boolean;
setFilter: Setter<Filter>;
}
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info, nixOnly } = props;
// Bootstrapping
const [installing, setInstalling] = createSignal<boolean>(false);
// Later only updates
const [updating, setUpdating] = createSignal<boolean>(false);
const navigate = useNavigate();
const handleInstall = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy?.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setInstalling(true);
await toast.promise(
callApi("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
override_target_host: info?.deploy.targetHost,
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
}),
{
loading: "Installing...",
success: "Installed",
error: "Failed to install",
},
);
setInstalling(false);
};
const handleUpdate = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setUpdating(true);
await toast.promise(
callApi("update_machines", {
base_path: active_clan,
machines: [
{
name: name,
deploy: {
targetHost: info?.deploy.targetHost,
},
},
],
}),
{
loading: "Updating...",
success: "Updated",
error: "Failed to update",
},
);
setUpdating(false);
};
return (
<div class="m-2 w-64 rounded-lg border p-3 border-def-2">
<figure class="h-fit rounded-xl border bg-def-2 border-def-5">
<RndThumbnail name={name} width={220} height={120} />
</figure>
<div class="flex-row justify-between gap-4 px-2 pt-2">
<div class="flex flex-col">
<A href={`/machines/${name}`}>
<Typography hierarchy="title" size="m" weight="bold">
{name}
</Typography>
</A>
<div class="flex justify-between text-slate-600">
<div class="flex flex-nowrap">
<span class="h-4">
<Icon icon="Flash" class="h-4" font-size="inherit" />
</span>
<Typography hierarchy="body" size="s" weight="medium">
<Show when={info}>
{(d) => d()?.description || "no description"}
</Show>
</Typography>
</div>
<div class="self-end">
<Menu
popoverid={`menu-${props.name}`}
label={<Icon icon={"More"} />}
>
<ul class="z-[1] w-64 bg-white p-2 shadow ">
<li>
<Button
variant="ghost"
class="w-full"
onClick={() => {
navigate("/machines/" + name);
}}
>
Details
</Button>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || installing(),
}}
>
<Button
variant="ghost"
class="w-full"
onClick={handleInstall}
>
Install
</Button>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || updating(),
}}
>
<Button
variant="ghost"
class="w-full"
onClick={handleUpdate}
>
Update
</Button>
</li>
</ul>
</Menu>
</div>
</div>
{/* <div class="text-slate-600">
<Show when={info}>
{(d) => (
<>
<Show when={d().tags}>
{(tags) => (
<span class="flex gap-1">
<For each={tags()}>
{(tag) => (
<button
type="button"
onClick={() =>
props.setFilter((prev) => {
if (prev.tags.includes(tag)) {
return prev;
}
return {
...prev,
tags: [...prev.tags, tag],
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</span>
)}
</Show>
{d()?.deploy?.targetHost}
</>
)}
</Show>
</div> */}
</div>
</div>
</div>
);
};

View File

@@ -1,8 +1,7 @@
import { createSignal, Show } from "solid-js";
import { createSignal } from "solid-js";
import { Typography } from "@/src/components/Typography";
import { SidebarFlyout } from "./SidebarFlyout";
import "./css/sidebar.css";
import Icon from "../icon";
interface SidebarProps {
clanName: string;
@@ -54,16 +53,8 @@ export const SidebarHeader = (props: SidebarProps) => {
return (
<header class="sidebar__header">
<div onClick={handleClick} class="sidebar__header__inner">
{/* <ClanProfile clanName={props.clanName} showFlyout={showFlyout} /> */}
<div class="w-full pl-1 text-white">
<ClanTitle clanName={props.clanName} />
</div>
<Show
when={showFlyout}
fallback={<Icon size={12} class="text-white" icon="CaretDown" />}
>
<Icon size={12} class="text-white" icon="CaretDown" />
</Show>
<ClanProfile clanName={props.clanName} showFlyout={showFlyout} />
<ClanTitle clanName={props.clanName} />
</div>
{showFlyout() && <SidebarFlyout />}
</header>

View File

@@ -21,7 +21,7 @@ export default function Accordion(props: AccordionProps) {
fallback={
<Button
endIcon={<Icon size={12} icon={"CaretDown"} />}
variant="ghost"
variant="light"
size="s"
>
{props.title}
@@ -30,7 +30,7 @@ export default function Accordion(props: AccordionProps) {
>
<Button
endIcon={<Icon size={12} icon={"CaretUp"} />}
variant="ghost"
variant="dark"
size="s"
>
{props.title}

View File

@@ -1,11 +0,0 @@
.button--ghost-hover:hover {
@apply hover:bg-secondary-100 hover:text-secondary-900;
}
.button--ghost-focus:focus {
@apply focus:bg-secondary-200 focus:text-secondary-900;
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-inner-primary-active;
}

View File

@@ -1,6 +1,5 @@
@import "./button-light.css";
@import "./button-dark.css";
@import "./button-ghost.css";
.button {
@apply inline-flex items-center flex-shrink gap-1 justify-center p-4 font-semibold;

View File

@@ -26,9 +26,10 @@ const variantColors: (
!disabled && "button--light-active", // Active state
),
ghost: cx(
!disabled && "button--ghost-hover", // Hover state
!disabled && "button--ghost-focus", // Focus state
!disabled && "button--ghost-active", // Active state
// "shadow-inner-secondary",
!disabled && "hover:bg-secondary-200 hover:text-secondary-900", // Hover state
!disabled && "focus:bg-secondary-200 focus:text-secondary-900", // Focus state
!disabled && "button--light-active", // Active state
),
});

View File

@@ -1,76 +0,0 @@
.machine-item {
@apply col-span-1 flex flex-col items-center;
position: relative;
padding: theme(padding.2);
cursor: pointer;
}
.machine-item__thumb-wrapper {
position: relative;
padding: theme(padding.4);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item__thumb {
@apply rounded-md bg-secondary-100 border border-secondary-200;
position: relative;
z-index: 20;
overflow: hidden;
transition: transform 0.24s ease-in-out;
}
.machine-item__header {
@apply flex flex-col justify-center items-center;
position: relative;
z-index: 20;
transition: transform 0.18s 0.04s ease-in-out;
}
.machine-item__pseudo {
@apply bg-secondary-50;
position: absolute;
z-index: 10;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 1px solid theme(borderColor.secondary.100);
border-radius: theme(borderRadius.md);
transition:
transform 0.16s ease-in-out,
opacity 0.08s ease-in-out;
}
.machine-item:hover {
& .machine-item__pseudo {
transform: scale(1);
opacity: 1;
}
& .machine-item__thumb {
transform: scale(1.02);
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.1),
0 8px 20px rgba(0, 0, 0, 0.15),
0 12px 40px rgba(0, 0, 0, 0.08);
}
& .machine-item__header {
transform: translateY(4px);
}
}
.machine-item:not(:hover) .machine-item__pseudo {
transform: scale(0.94);
opacity: 0;
}

View File

@@ -1,135 +0,0 @@
import { createSignal, For, Setter, Show } from "solid-js";
import { callApi, SuccessQuery } from "../../api";
import { activeURI } from "../../App";
import toast from "solid-toast";
import { A, useNavigate } from "@solidjs/router";
import { RndThumbnail } from "../noiseThumbnail";
import { Filter } from "../../routes/machines";
import { Typography } from "../Typography";
import "./css/index.css";
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info?: MachineDetails;
nixOnly?: boolean;
setFilter: Setter<Filter>;
}
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info, nixOnly } = props;
// Bootstrapping
const [installing, setInstalling] = createSignal<boolean>(false);
// Later only updates
const [updating, setUpdating] = createSignal<boolean>(false);
const navigate = useNavigate();
const handleInstall = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy?.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setInstalling(true);
await toast.promise(
callApi("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
override_target_host: info?.deploy.targetHost,
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
}),
{
loading: "Installing...",
success: "Installed",
error: "Failed to install",
},
);
setInstalling(false);
};
const handleUpdate = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeURI();
if (!active_clan) {
toast.error("No active clan selected");
return;
}
if (!info?.deploy.targetHost) {
toast.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setUpdating(true);
await toast.promise(
callApi("update_machines", {
base_path: active_clan,
machines: [
{
name: name,
deploy: {
targetHost: info?.deploy.targetHost,
},
},
],
}),
{
loading: "Updating...",
success: "Updated",
error: "Failed to update",
},
);
setUpdating(false);
};
return (
<div class="machine-item">
<A href={`/machines/${name}`}>
<div class="machine-item__thumb-wrapper">
<div class="machine-item__thumb">
<RndThumbnail name={name} width={100} height={100} />
</div>
<div class="machine-item__pseudo" />
</div>
<header class="machine-item__header">
<Typography
class="text-center"
hierarchy="body"
size="s"
weight="bold"
color="primary"
>
{name}
</Typography>
</header>
</A>
</div>
);
};

View File

@@ -96,102 +96,9 @@ html {
cursor: pointer;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::marker {
.accordeon__header::-webkit-details-marker {
display: none;
}
.accordeon__body {
}
.machine-item-loader {
@apply col-span-1 flex flex-col items-center;
display: flex;
justify-content: center;
position: relative;
padding: theme(padding.2);
border-radius: theme(borderRadius.md);
overflow: hidden;
cursor: pointer;
}
.machine-item-loader__thumb-wrapper {
position: relative;
z-index: 20;
padding: theme(padding.4);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item-loader__thumb {
position: relative;
width: 100px;
height: 100px;
background: theme(backgroundColor.secondary.100);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item-loader__headline {
position: relative;
z-index: 20;
width: 90%;
height: 20px;
background: theme(backgroundColor.secondary.100);
border-radius: theme(borderRadius.sm);
overflow: hidden;
}
.machine-item-loader__cover {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.machine-item-loader__loader {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to right,
transparent 20%,
theme(backgroundColor.secondary.200) 50%,
transparent 80%
);
background-size: 400px 100%;
animation: loader 4s linear infinite;
transition: all 0.56s ease;
}
.machine-item-loader__cover .machine-item-loader__loader {
background: linear-gradient(
to right,
transparent 20%,
theme(backgroundColor.secondary.50) 50%,
transparent 80%
);
}
@keyframes loader {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}

View File

@@ -21,8 +21,6 @@ import { ModuleDetails as AddModule } from "./routes/modules/add";
import { ApiTester } from "./api_test";
import { IconVariant } from "./components/icon";
import { Components } from "./routes/components";
import { activeURI } from "./App";
import { VarsForMachine, VarsStep } from "./routes/machines/install/vars-step";
export const client = new QueryClient();
@@ -33,6 +31,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
);
}
if (import.meta.env.DEV) {
console.log("Development mode");
// Load the debugger in development mode
@@ -74,12 +73,6 @@ export const routes: AppRoute[] = [
hidden: true,
component: () => <MachineDetails />,
},
{
path: "/:id/vars",
label: "Vars",
hidden: true,
component: () => <VarsForMachine />,
},
],
},
{

View File

@@ -11,10 +11,7 @@ export const createModulesQuery = (
) =>
createQuery(() => ({
queryKey: [uri, "list_modules"],
placeholderData: {
localModules: {},
modulesPerSource: {},
},
placeholderData: [],
enabled: !!uri,
queryFn: async () => {
console.log({ uri });
@@ -26,13 +23,15 @@ export const createModulesQuery = (
if (response.status === "error") {
toast.error("Failed to fetch data");
} else {
return response.data;
if (!filter) {
return Object.entries(response.data);
}
return Object.entries(response.data).filter(([key, value]) =>
filter.features.every((f) => (value.features || []).includes(f)),
);
}
}
return {
localModules: {},
modulesPerSource: {},
};
return [];
},
}));

View File

@@ -22,8 +22,6 @@ import toast from "solid-toast";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase";
import { Modal } from "@/src/components/modal";
import Fieldset from "@/src/Form/fieldset";
import Accordion from "@/src/components/accordion";
interface Wifi extends FieldValues {
ssid: string;
@@ -235,23 +233,16 @@ export const Flash = () => {
</div>
</div>
</Modal>
<div class="w-full self-stretch p-8">
{/* <Typography tag="p" hierarchy="body" size="default" color="primary">
<div class="p-4">
<Typography tag="p" hierarchy="body" size="default" color="primary">
USB Utility image.
</Typography>
<Typography tag="p" hierarchy="body" size="default" color="secondary">
Will make bootstrapping new machines easier by providing secure remote
connection to any machine when plugged in.
</Typography> */}
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Fieldset legend="Authorized SSH Keys">
<Typography hierarchy="body" size="s" weight="medium">
Provide your SSH public key. For secure and passwordless SSH
connections.
</Typography>
</Typography>
<Form onSubmit={handleSubmit}>
<div class="my-4">
<Field name="sshKeys" type="File[]">
{(field, props) => (
<>
@@ -276,72 +267,146 @@ export const Flash = () => {
}}
value={field.value}
error={field.error}
//helperText="Provide your SSH public key. For secure and passwordless SSH connections."
//label="Authorized SSH Keys"
helperText="Provide your SSH public key. For secure and passwordless SSH connections."
label="Authorized SSH Keys"
multiple
/>
</>
)}
</Field>
</Fieldset>
<Fieldset legend="General">
<Field name="disk" validate={[required("This field is required")]}>
{(field, props) => (
<>
<SelectInput
loading={deviceQuery.isFetching}
selectProps={props}
label="Flash Disk"
labelProps={{
labelAction: (
<Button
disabled={isFlashing()}
class="ml-auto"
variant="ghost"
size="s"
type="button"
startIcon={<Icon icon="Update" />}
onClick={() => deviceQuery.refetch()}
/>
),
}}
value={field.value || ""}
error={field.error}
required
placeholder="Select a drive"
options={
deviceQuery.data?.blockdevices.map((d) => ({
value: d.path,
label: `${d.path} -- ${d.size} bytes`,
})) || []
}
/>
</>
)}
</Field>
</Fieldset>
</div>
<Fieldset legend="Network Settings">
<Field name="disk" validate={[required("This field is required")]}>
{(field, props) => (
<SelectInput
loading={deviceQuery.isFetching}
selectProps={props}
label="Flash Disk"
labelProps={{
labelAction: (
<Button
disabled={isFlashing()}
class="ml-auto"
variant="ghost"
size="s"
type="button"
startIcon={<Icon icon="Update" />}
onClick={() => deviceQuery.refetch()}
/>
),
}}
value={field.value || ""}
error={field.error}
required
placeholder="Select a drive where the clan-installer will be flashed to"
options={
deviceQuery.data?.blockdevices.map((d) => ({
value: d.path,
label: `${d.path} -- ${d.size} bytes`,
})) || []
}
/>
)}
</Field>
{/* WiFi Networks */}
<div class="my-4 py-2">
<FieldLayout
label={<InputLabel>Networks</InputLabel>}
label={<InputLabel class="mb-4">Networks</InputLabel>}
field={
<div class="flex w-full justify-end">
<>
<Button
type="button"
size="s"
variant="light"
onClick={addWifiNetwork}
startIcon={<Icon size={12} icon="Plus" />}
startIcon={<Icon icon="Plus" />}
>
WiFi Network
</Button>
</div>
</>
}
/>
</Fieldset>
<For each={wifiNetworks()}>
{(network, index) => (
<div class="flex w-full gap-2">
<div class="mb-2 grid w-full grid-cols-6 gap-2 align-middle">
<Field
name={`wifi.${index()}.ssid`}
validate={[required("SSID is required")]}
>
{(field, props) => (
<TextInput
inputProps={props}
label="SSID"
value={field.value ?? ""}
error={field.error}
class="col-span-full "
required
/>
)}
</Field>
<Field
name={`wifi.${index()}.password`}
validate={[required("Password is required")]}
>
{(field, props) => (
<TextInput
class="col-span-full"
inputProps={{
...props,
type: passwordVisibility()[index()]
? "text"
: "password",
}}
label="Password"
value={field.value ?? ""}
error={field.error}
// adornment={{
// position: "end",
// content: (
// <Button
// variant="light"
// type="button"
// class="flex justify-center opacity-70"
// onClick={() =>
// togglePasswordVisibility(index())
// }
// startIcon={
// passwordVisibility()[index()] ? (
// <Icon icon="EyeClose" />
// ) : (
// <Icon icon="EyeOpen" />
// )
// }
// ></Button>
// ),
// }}
required
/>
)}
</Field>
</div>
<Button
type="button"
variant="light"
class="h-10"
size="s"
onClick={() => removeWifiNetwork(index())}
startIcon={<Icon icon="Trash" />}
></Button>
</div>
)}
</For>
</div>
<Accordion title="Advanced">
<Fieldset>
<div class=" " tabindex="0">
<input type="checkbox" />
<div class=" px-0">
<InputLabel class="mb-4">Advanced</InputLabel>
</div>
<div class="">
<Field
name="machine.flake"
validate={[required("This field is required")]}
@@ -443,8 +508,10 @@ export const Flash = () => {
</>
)}
</Field>
</Fieldset>
</Accordion>
</div>
</div>
<hr></hr>
<div class="mt-2 flex justify-end pt-2">
<Button
class="self-end"

View File

@@ -11,6 +11,7 @@ import { Match, Switch } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { DynForm } from "@/src/Form/form";
import { Typography } from "@/src/components/Typography";
import Fieldset from "@/src/Form/fieldset";
import Accordion from "@/src/components/accordion";

View File

@@ -1,4 +1,9 @@
import { callApi, SuccessData } from "@/src/api";
import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import {
createForm,
FieldValues,
@@ -6,15 +11,9 @@ import {
getValues,
setValue,
} from "@modular-forms/solid";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import Accordion from "@/src/components/accordion";
import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header";
@@ -27,7 +26,6 @@ import { DiskStep, DiskValues } from "./install/disk-step";
import { SummaryStep } from "./install/summary-step";
import cx from "classnames";
import { VarsStep, VarsValues } from "./install/vars-step";
import Fieldset from "@/src/Form/fieldset";
type MachineFormInterface = MachineData & {
sshKey?: File;
@@ -244,7 +242,7 @@ const InstallMachine = (props: InstallMachineProps) => {
<div class="flex flex-col items-center gap-3 fg-def-1">
<Typography
classList={{
[cx("bg-inv-4 fg-inv-1")]: idx === step(),
[cx("bg-inv-4 fg-inv-1")]: idx == step(),
[cx("bg-def-4 fg-def-1")]: idx < step(),
}}
color="inherit"
@@ -318,7 +316,21 @@ const InstallMachine = (props: InstallMachineProps) => {
/>
</Match>
<Match when={step() === "3"}>
<div>TODO: vars</div>
<VarsStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeURI()}
footer={<Footer />}
handleNext={(data) => {
// const prev = getValue(formStore, "2");
// setValue(formStore, "2", { ...prev, ...data });
handleNext();
}}
initial={{
...getValue(formStore, "3"),
}}
/>
</Match>
<Match when={step() === "4"}>
<SummaryStep
@@ -404,10 +416,6 @@ const MachineForm = (props: MachineDetailsProps) => {
const [installModalOpen, setInstallModalOpen] = createSignal(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const handleSubmit = async (values: MachineFormInterface) => {
console.log("submitting", values);
@@ -441,40 +449,7 @@ const MachineForm = (props: MachineDetailsProps) => {
return null;
};
const generatorsQuery = createQuery(() => ({
queryKey: [activeURI(), machineName(), "generators"],
queryFn: async () => {
const machine_name = machineName();
const base_dir = activeURI();
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
const handleUpdateButton = async () => {
const t = toast.loading("Checking for generators...");
await generatorsQuery.refetch();
toast.dismiss(t);
if (generatorsQuery.data?.length !== 0) {
navigate(`/machines/${machineName()}/vars`);
} else {
handleUpdate();
}
};
const [isUpdating, setIsUpdating] = createSignal(false);
const handleUpdate = async () => {
if (isUpdating()) {
return;
}
const curr_uri = activeURI();
if (!curr_uri) {
return;
@@ -488,7 +463,6 @@ const MachineForm = (props: MachineDetailsProps) => {
const target = targetHost();
const loading_toast = toast.loading("Updating machine...");
setIsUpdating(true);
const r = await callApi("update_machines", {
base_path: curr_uri,
machines: [
@@ -500,7 +474,6 @@ const MachineForm = (props: MachineDetailsProps) => {
},
],
});
setIsUpdating(false);
toast.dismiss(loading_toast);
if (r.status === "error") {
@@ -510,82 +483,85 @@ const MachineForm = (props: MachineDetailsProps) => {
toast.success("Machine updated successfully");
}
};
createEffect(() => {
const action = searchParams.action;
if (action === "update") {
setSearchParams({ action: undefined });
handleUpdate();
}
});
return (
<>
<div class="flex flex-col gap-6">
<div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
<div class="flex items-center gap-3">
<div class="w-fit" data-tip="Machine must be online">
{/* <Button
class="w-full"
size="s"
// disabled={!online()}
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon icon="Flash" />}
>
Install
</Button> */}
</div>
{/* <Typography hierarchy="label" size="default">
Installs the system for the first time. Used to bootstrap the
remote device.
</Typography> */}
</div>
<div class="flex items-center gap-3">
<div class="button-group flex">
<Button
variant="light"
class="w-full"
size="s"
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon size={14} icon="Flash" />}
>
Install
</Button>
<Button
variant="light"
class="w-full"
size="s"
onClick={() => handleUpdate()}
endIcon={<Icon size={12} icon="Update" />}
>
Update
</Button>
</div>
<div class=" w-fit" data-tip="Machine must be online"></div>
{/* <Typography hierarchy="label" size="default">
Update the system if changes should be synced after the
installation process.
</Typography> */}
</div>
</div>
<div class="p-4">
<span class="mb-2 flex w-full justify-center">
<MachineAvatar name={machineName()} />
</span>
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Fieldset legend="General">
<Field name="machine.name">
<div class="flex flex-col gap-6 p-4">
<span class="mb-2 flex w-full justify-center">
<MachineAvatar name={machineName()} />
</span>
<Form onSubmit={handleSubmit} class="flex flex-col gap-6">
<Field name="machine.name">
{(field, props) => (
<TextInput
inputProps={props}
label="Name"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="machine.tags" type="string[]">
{(field, props) => (
<>
<FieldLayout
label={<InputLabel>Tags</InputLabel>}
field={
<span class="col-span-10">
<For each={field.value}>
{(tag) => (
<span class="mx-2 w-fit rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
)}
</For>
</span>
}
/>
</>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<TextInput
inputProps={props}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="hw_config">
{(field, props) => (
<FieldLayout
label={<InputLabel>Hardware Configuration</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
<Field name="disk_schema.schema_name">
{(field, props) => (
<>
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</>
)}
</Field>
<div class=" col-span-full" tabindex="0">
<input type="checkbox" />
<div class=" px-0 text-xl ">Connection Settings</div>
<div class="">
<Field name="machine.deploy.targetHost">
{(field, props) => (
<TextInput
inputProps={props}
label="Name"
label="Target Host"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
@@ -593,106 +569,73 @@ const MachineForm = (props: MachineDetailsProps) => {
/>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<TextInput
inputProps={props}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
/>
)}
</Field>
<Field name="machine.tags" type="string[]">
{(field, props) => (
<div class="flex items-center gap-4">
<Typography hierarchy="label" size="default" weight="bold">
Tags{" "}
</Typography>
<For each={field.value}>
{(tag) => (
<span class="mx-2 w-fit rounded-full px-3 py-0.5 bg-inv-4 fg-inv-1">
<Typography
hierarchy="label"
size="s"
inverted={true}
>
{tag}
</Typography>
</span>
)}
</For>
</div>
)}
</Field>
</Fieldset>
</div>
</div>
<Fieldset legend="Hardware">
<Field name="hw_config">
{(field, props) => (
<FieldLayout
label={<InputLabel>Hardware Configuration</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
<hr />
<Field name="disk_schema.schema_name">
{(field, props) => (
<>
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</>
)}
</Field>
</Fieldset>
<Accordion title="Connection Settings">
<Fieldset>
<Field name="machine.deploy.targetHost">
{(field, props) => (
<TextInput
inputProps={props}
label="Target Host"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
</Fieldset>
</Accordion>
{
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
<Button
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Update edits
</Button>
</footer>
}
</Form>
</div>
{
<div class=" col-span-full justify-end">
<Button
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Save
</Button>
</div>
}
</Form>
</div>
<Modal
title={`Install machine`}
open={installModalOpen()}
handleClose={() => setInstallModalOpen(false)}
class="min-w-[600px]"
>
<InstallMachine
name={machineName()}
targetHost={getValue(formStore, "machine.deploy.targetHost")}
machine={props.initialData}
/>
</Modal>
<div class="">
<div class=""></div>
<span class="text-xl text-primary-800">Actions</span>
<div class="my-4 flex flex-col gap-6">
<span class="max-w-md">
Installs the system for the first time. Used to bootstrap the remote
device.
</span>
<div class=" w-fit" data-tip="Machine must be online">
<Button
class="w-full"
// disabled={!online()}
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon icon="Flash" />}
>
Install
</Button>
</div>
<Modal
title={`Install machine`}
open={installModalOpen()}
handleClose={() => setInstallModalOpen(false)}
class="min-w-[600px]"
>
<InstallMachine
name={machineName()}
targetHost={getValue(formStore, "machine.deploy.targetHost")}
machine={props.initialData}
/>
</Modal>
<span class="max-w-md">
Update the system if changes should be synced after the installation
process.
</span>
<div class=" w-fit" data-tip="Machine must be online">
<Button
class="w-full"
// disabled={!online()}
onClick={() => handleUpdate()}
endIcon={<Icon icon="Update" />}
>
Update
</Button>
</div>
</div>
</div>
</>
);
};

View File

@@ -5,73 +5,31 @@ import {
validate,
FieldValues,
} from "@modular-forms/solid";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createQuery } from "@tanstack/solid-query";
import { StepProps } from "./hardware-step";
import { Typography } from "@/src/components/Typography";
import { Group } from "@/src/components/group";
import { For, Match, Show, Switch } from "solid-js";
import { TextInput } from "@/src/Form/fields";
import toast from "solid-toast";
import { useNavigate, useParams } from "@solidjs/router";
import { activeURI } from "@/src/App";
export type VarsValues = FieldValues & Record<string, Record<string, string>>;
export type VarsValues = FieldValues & Record<string, string>;
export interface VarsStepProps {
machine_id: string;
dir: string;
}
export const VarsStep = (props: VarsStepProps) => {
const [formStore, { Form, Field }] = createForm<VarsValues>({});
const navigate = useNavigate();
const queryClient = useQueryClient();
export const VarsStep = (props: StepProps<VarsValues>) => {
const [formStore, { Form, Field }] = createForm<VarsValues>({
initialValues: { ...props.initial, schema: "single-disk" },
});
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
console.log("Submit Disk", { values });
// sanitize the values back (replace __dot__)
// This hack is needed because we are using "." in the keys of the form
const sanitizedValues = Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key.replaceAll("__dot__", "."),
Object.fromEntries(
Object.entries(value).map(([k, v]) => [
k.replaceAll("__dot__", "."),
v,
]),
),
]),
) as VarsValues;
const valid = await validate(formStore);
if (generatorsQuery.data === undefined) {
toast.error("Error fetching data");
return;
}
const loading_toast = toast.loading("Generating vars...");
const result = await callApi("generate_vars_for_machine", {
machine_name: props.machine_id,
base_dir: props.dir,
generators: generatorsQuery.data.map((generator) => generator.name),
all_prompt_values: sanitizedValues,
});
queryClient.invalidateQueries({
queryKey: [props.dir, props.machine_id, "generators"],
});
toast.dismiss(loading_toast);
if (result.status === "error") {
toast.error(result.errors[0].message);
return;
}
if (result.status === "success") {
toast.success("Vars saved successfully");
navigate(`/machines/${props.machine_id}?action=update`);
}
console.log("Valid", valid);
if (!valid) return;
props.handleNext(values);
};
const generatorsQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "generators"],
queryFn: async () => {
const result = await callApi("get_generators_closure", {
const result = await callApi("get_generators", {
base_dir: props.dir,
machine_name: props.machine_id,
});
@@ -103,33 +61,14 @@ export const VarsStep = (props: VarsStepProps) => {
{generator.share ? "True" : "False"}
</div>
<For each={generator.prompts}>
{(prompt) => (
{(f) => (
<Group>
<Typography hierarchy="label" size="s">
{!prompt.previous_value ? "Required" : "Optional"}
{!f.previous_value ? "Required" : "Optional"}
</Typography>
<Typography hierarchy="label" size="s">
{prompt.name}
{f.name}
</Typography>
{/* Avoid nesting issue in case of a "." */}
<Field
name={`${generator.name.replaceAll(".", "__dot__")}.${prompt.name.replaceAll(".", "__dot__")}`}
>
{(field, props) => (
<TextInput
inputProps={{
...props,
type:
prompt.prompt_type === "hidden"
? "password"
: "text",
}}
label={prompt.description}
value={prompt.previous_value ?? ""}
error={field.error}
/>
)}
</Field>
</Group>
)}
</For>
@@ -141,17 +80,7 @@ export const VarsStep = (props: VarsStepProps) => {
</Switch>
</div>
</div>
<button type="submit">Submit</button>
<Show when={generatorsQuery.isFetched}>{props.footer}</Show>
</Form>
);
};
export const VarsForMachine = () => {
const params = useParams();
return (
<Show when={activeURI()}>
{(uri) => <VarsStep machine_id={params.id} dir={uri()} />}
</Show>
);
};

View File

@@ -9,7 +9,7 @@ import {
import { activeURI } from "@/src/App";
import { callApi, OperationResponse } from "@/src/api";
import toast from "solid-toast";
import { MachineListItem } from "@/src/components/machine-list-item";
import { MachineListItem } from "@/src/components/MachineListItem";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { useNavigate } from "@solidjs/router";
import { Button } from "@/src/components/button";
@@ -114,6 +114,16 @@ export const MachineListView: Component = () => {
/>
<div>
<div class="my-1 flex w-full gap-2 p-2">
<div class="flex w-full justify-end px-4 py-1">
<div class="flex">
<Button
// onClick={() => navigate("create")}
size="s"
variant="light"
startIcon={<Icon icon="Filter" />}
></Button>
</div>
</div>
<For each={filter().tags.sort()}>
{(tag) => (
<button
@@ -134,39 +144,22 @@ export const MachineListView: Component = () => {
)}
</For>
</div>
{/* </Show> */}
<Switch>
<Match when={inventoryQuery.isLoading}>
{/* Loading skeleton */}
<div class="grid grid-cols-4"></div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
<div>
<div class=" m-2 shadow-lg">
<figure class="pl-2">
<div class=" size-12"></div>
</figure>
<div class="">
<h2 class="">
<div class=" h-12 w-80"></div>
</h2>
<div class=" h-8 w-72"></div>
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
</Match>
<Match
@@ -187,10 +180,10 @@ export const MachineListView: Component = () => {
</Match>
<Match when={!inventoryQuery.isLoading}>
<div
class="my-4 grid gap-6 p-6"
class="my-4 flex flex-wrap gap-6 px-3 py-2"
classList={{
"grid-cols-1": view() === "list",
"grid-cols-4": view() === "grid",
"flex-col": view() === "list",
"": view() === "grid",
}}
>
<For each={inventoryMachines()}>

View File

@@ -16,11 +16,11 @@ export const ModuleDetails = () => {
<BackButton />
<div class="p-2">
<h3 class="text-2xl">{params.id}</h3>
{/* <Switch>
<Switch>
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
{(d) => <AddModule data={d()[1]} id={d()[0]} />}
</Match>
</Switch> */}
</Switch>
</div>
</div>
);
@@ -40,7 +40,7 @@ export const AddModule = (props: AddModuleProps) => {
<Switch fallback="loading">
<Match when={tags.data}>
{(tags) => (
<For each={Object.keys(props.data.roles)}>
<For each={props.data.roles}>
{(role) => (
<>
<div class="text-neutral-600">{role}s</div>

View File

@@ -21,11 +21,11 @@ export const ModuleDetails = () => {
<BackButton />
<div class="p-2">
<h3 class="text-2xl">{params.id}</h3>
{/* <Switch>
<Switch>
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
{(d) => <Details data={d()[1]} id={d()[0]} />}
</Match>
</Switch> */}
</Switch>
</div>
</div>
);
@@ -85,24 +85,19 @@ const Details = (props: DetailsProps) => {
};
return (
<div class="flex w-full flex-col gap-2">
{/* TODO: bring this feature back */}
{/* <article class="prose">{props.data.description}</article> */}
{/* <span class="">Categories</span> */}
<article class="prose">{props.data.description}</article>
<span class="">Categories</span>
<div>
{/* TODO: bring this feature back */}
{/* <For each={props.data.categories}>
<For each={props.data.categories}>
{(c) => <div class=" m-1">{c}</div>}
</For> */}
</For>
</div>
<span class="">Roles</span>
<div>
<For each={Object.keys(props.data.roles)}>
{(r) => <div class=" m-1">{r}</div>}
</For>
<For each={props.data.roles}>{(r) => <div class=" m-1">{r}</div>}</For>
</div>
<div class="p-2">
{/* TODO: bring this feature back */}
{/* <SolidMarkdown>{props.data.readme}</SolidMarkdown> */}
<SolidMarkdown>{props.data.readme}</SolidMarkdown>
</div>
<div class="my-2 flex w-full gap-2">
<Button variant="light" onClick={add} startIcon={<Icon icon="Plus" />}>

View File

@@ -12,7 +12,7 @@ import { useQueryClient } from "@tanstack/solid-query";
import cx from "classnames";
import Icon from "@/src/components/icon";
export type ModuleInfo = SuccessData<"list_modules">["localModules"][string];
export type ModuleInfo = SuccessData<"list_modules">[string];
interface CategoryProps {
categories: string[];
@@ -28,7 +28,7 @@ const Categories = (props: CategoryProps) => {
};
interface RolesProps {
roles: Record<string, null>;
roles: string[];
}
const Roles = (props: RolesProps) => {
return (
@@ -38,7 +38,7 @@ const Roles = (props: RolesProps) => {
Service
</Typography>
</span>
{Object.keys(props.roles).map((role) => (
{props.roles.map((role) => (
<span class="">{role}</span>
))}
</div>
@@ -82,7 +82,7 @@ const ModuleItem = (props: {
<A href={`/modules/details/${name}`}>
<div class="">
<div class="flex flex-col">
{/* <Categories categories={info.categories} /> */}
<Categories categories={info.categories} />
<Typography hierarchy="title" size="m" weight="medium">
{name}
</Typography>
@@ -92,12 +92,11 @@ const ModuleItem = (props: {
<div class="w-full">
<Typography hierarchy="body" size="xs">
description
{/* TODO: {info.description} */}
{info.description}
</Typography>
</div>
</header>
<Roles roles={info.roles || {}} />
<Roles roles={info.roles || []} />
</div>
);
};
@@ -122,7 +121,7 @@ export const ModuleList = () => {
return (
<>
<Header
title="App Store"
title="Modules"
toolbar={
<>
<Button
@@ -162,46 +161,23 @@ export const ModuleList = () => {
<Switch fallback="Error">
<Match when={modulesQuery.isFetching}>Loading....</Match>
<Match when={modulesQuery.data}>
{(modules) => (
<div
class="grid gap-6 p-6"
classList={{
"grid-cols-1": view() === "list",
"grid-cols-2": view() === "grid",
}}
>
<For each={Object.entries(modules().modulesPerSource)}>
{([sourceName, v]) => (
<>
<div>
<Typography size="default" hierarchy="label">
{sourceName}
</Typography>
</div>
<For each={Object.entries(v)}>
{([moduleName, moduleInfo]) => (
<ModuleItem
info={moduleInfo}
name={moduleName}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</>
)}
</For>
<div>{"localModules"}</div>
<For each={Object.entries(modules().localModules)}>
{([moduleName, moduleInfo]) => (
<ModuleItem
info={moduleInfo}
name={moduleName}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</div>
)}
<div
class="grid gap-6 p-6"
classList={{
"grid-cols-1": view() === "list",
"grid-cols-2": view() === "grid",
}}
>
<For each={modulesQuery.data}>
{([k, v]) => (
<ModuleItem
info={v}
name={k}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</div>
</Match>
</Switch>
</>

View File

@@ -240,17 +240,17 @@ export default plugin.withOptions(
950: toRGB("#162324"),
},
secondary: {
50: toRGB("#F7F9FA"),
100: toRGB("#E7F2F4"),
200: toRGB("#D8E8EB"),
300: toRGB("#AFC6CA"),
400: toRGB("#90B2B7"),
500: toRGB("#7B9B9F"),
600: toRGB("#4F747A"),
700: toRGB("#415E63"),
800: toRGB("#446065"),
900: toRGB("#2C4347"),
950: toRGB("#0D1416"),
50: toRGB("#f7f9f9"),
100: toRGB("#e7f2f4"),
200: toRGB("#d7e8ea"),
300: toRGB("#afc6ca"),
400: toRGB("#8fb2b6"),
500: toRGB("#7b9a9e"),
600: toRGB("#4f747a"),
700: toRGB("#415e63"),
800: toRGB("#445f64"),
900: toRGB("#2b4347"),
950: toRGB("#0d1415"),
},
info: {
50: toRGB("#eff9ff"),

View File

@@ -30,9 +30,6 @@ export default defineConfig({
"@": path.resolve(__dirname, "./"), // Adjust the path as needed
},
},
optimizeDeps: {
include: ["debug", "extend"],
},
plugins: [
/*
Uncomment the following line to enable solid-devtools.