Compare commits
1 Commits
improve-co
...
Qubasa-rep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60c3d984d |
@@ -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 =
|
||||
{
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -10,6 +10,9 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
clan.inventory.modules = {
|
||||
hello-world = module;
|
||||
};
|
||||
clan.modules = {
|
||||
hello-world = module;
|
||||
};
|
||||
|
||||
20
flake.lock
generated
20
flake.lock
generated
@@ -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": {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
21
module.nix
21
module.nix
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
5035
pkgs/webview-ui/app/package-lock.json
generated
5035
pkgs/webview-ui/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
224
pkgs/webview-ui/app/src/components/MachineListItem.tsx
Normal file
224
pkgs/webview-ui/app/src/components/MachineListItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 [];
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user