Merge pull request 'lib.values: init getPrio' (#2559) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
@@ -101,6 +101,7 @@ in
|
|||||||
# Those options are interfaced by the CLI
|
# Those options are interfaced by the CLI
|
||||||
# We don't specify the type here, for better performance.
|
# We don't specify the type here, for better performance.
|
||||||
inventory = lib.mkOption { type = lib.types.raw; };
|
inventory = lib.mkOption { type = lib.types.raw; };
|
||||||
|
inventoryValuesPrios = lib.mkOption { type = lib.types.raw; };
|
||||||
# all inventory module schemas
|
# all inventory module schemas
|
||||||
moduleSchemas = lib.mkOption { type = lib.types.raw; };
|
moduleSchemas = lib.mkOption { type = lib.types.raw; };
|
||||||
inventoryFile = lib.mkOption { type = lib.types.raw; };
|
inventoryFile = lib.mkOption { type = lib.types.raw; };
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ in
|
|||||||
inherit serviceConfigs;
|
inherit serviceConfigs;
|
||||||
inherit (clan-core) clanModules;
|
inherit (clan-core) clanModules;
|
||||||
inherit inventoryFile;
|
inherit inventoryFile;
|
||||||
|
inventoryValuesPrios = (clan-core.lib.values.getPrios { options = inventory.options; });
|
||||||
inventory = config.inventory;
|
inventory = config.inventory;
|
||||||
meta = config.inventory.meta;
|
meta = config.inventory.meta;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ in
|
|||||||
buildClan = import ./build-clan { inherit lib nixpkgs clan-core; };
|
buildClan = import ./build-clan { inherit lib nixpkgs clan-core; };
|
||||||
facts = import ./facts.nix { inherit lib; };
|
facts = import ./facts.nix { inherit lib; };
|
||||||
inventory = import ./inventory { inherit lib clan-core; };
|
inventory = import ./inventory { inherit lib clan-core; };
|
||||||
|
values = import ./values { inherit lib; };
|
||||||
jsonschema = import ./jsonschema { inherit lib; };
|
jsonschema = import ./jsonschema { inherit lib; };
|
||||||
modules = import ./frontmatter {
|
modules = import ./frontmatter {
|
||||||
inherit lib;
|
inherit lib;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
./jsonschema/flake-module.nix
|
./jsonschema/flake-module.nix
|
||||||
./inventory/flake-module.nix
|
./inventory/flake-module.nix
|
||||||
./build-clan/flake-module.nix
|
./build-clan/flake-module.nix
|
||||||
|
./values/flake-module.nix
|
||||||
];
|
];
|
||||||
flake.lib = import ./default.nix {
|
flake.lib = import ./default.nix {
|
||||||
inherit lib inputs;
|
inherit lib inputs;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{ lib, config, ... }:
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
options,
|
||||||
|
...
|
||||||
|
}:
|
||||||
let
|
let
|
||||||
types = lib.types;
|
types = lib.types;
|
||||||
|
|
||||||
@@ -92,6 +97,12 @@ in
|
|||||||
./assertions.nix
|
./assertions.nix
|
||||||
];
|
];
|
||||||
options = {
|
options = {
|
||||||
|
options = lib.mkOption {
|
||||||
|
internal = true;
|
||||||
|
visible = false;
|
||||||
|
type = types.raw;
|
||||||
|
default = options;
|
||||||
|
};
|
||||||
modules = lib.mkOption {
|
modules = lib.mkOption {
|
||||||
type = types.attrsOf types.path;
|
type = types.attrsOf types.path;
|
||||||
default = { };
|
default = { };
|
||||||
|
|||||||
90
lib/values/default.nix
Normal file
90
lib/values/default.nix
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
lib ? import <nixpkgs/lib>,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
filterOptions = lib.filterAttrs (
|
||||||
|
name: _:
|
||||||
|
!builtins.elem name [
|
||||||
|
"_module"
|
||||||
|
"_freeformOptions"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
getPrios =
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
filteredOptions = filterOptions options;
|
||||||
|
in
|
||||||
|
lib.mapAttrs (
|
||||||
|
_: opt:
|
||||||
|
let
|
||||||
|
prio = {
|
||||||
|
__prio = opt.highestPrio;
|
||||||
|
};
|
||||||
|
subOptions = opt.type.getSubOptions opt.loc;
|
||||||
|
|
||||||
|
attrDefinitions = (lib.modules.mergeAttrDefinitionsWithPrio opt);
|
||||||
|
zipDefs = builtins.zipAttrsWith (_ns: vs: vs);
|
||||||
|
defs = zipDefs opt.definitions;
|
||||||
|
|
||||||
|
prioPerValue =
|
||||||
|
{ type, defs }:
|
||||||
|
lib.mapAttrs (
|
||||||
|
attrName: prioSet:
|
||||||
|
let
|
||||||
|
# Evaluate the submodule
|
||||||
|
options = filterOptions subOptions;
|
||||||
|
modules = (
|
||||||
|
[
|
||||||
|
{ inherit options; }
|
||||||
|
]
|
||||||
|
++ map (config: { inherit config; }) defs.${attrName}
|
||||||
|
);
|
||||||
|
submoduleEval = lib.evalModules {
|
||||||
|
inherit modules;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
(lib.optionalAttrs (prioSet ? highestPrio) {
|
||||||
|
__prio = prioSet.highestPrio;
|
||||||
|
# inherit defs options;
|
||||||
|
})
|
||||||
|
// (
|
||||||
|
if type.nestedTypes.elemType.name == "submodule" then
|
||||||
|
getPrios { options = submoduleEval.options; }
|
||||||
|
else
|
||||||
|
# Nested attrsOf
|
||||||
|
(lib.optionalAttrs (type.nestedTypes.elemType.name == "attrsOf") (
|
||||||
|
prioPerValue {
|
||||||
|
type = type.nestedTypes.elemType;
|
||||||
|
defs = zipDefs defs.${attrName};
|
||||||
|
} prioSet.value
|
||||||
|
))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
attributePrios = prioPerValue {
|
||||||
|
type = opt.type;
|
||||||
|
inherit defs;
|
||||||
|
} attrDefinitions;
|
||||||
|
in
|
||||||
|
if opt ? type && opt.type.name == "submodule" then
|
||||||
|
prio // (getPrios { options = subOptions; })
|
||||||
|
else if opt ? type && opt.type.name == "attrsOf" then
|
||||||
|
# prio // attributePrios
|
||||||
|
# else if
|
||||||
|
# opt ? type && opt.type.name == "attrsOf" && opt.type.nestedTypes.elemType.name == "attrsOf"
|
||||||
|
# then
|
||||||
|
# prio // attributePrios
|
||||||
|
# else if opt ? type && opt.type.name == "attrsOf" then
|
||||||
|
prio // attributePrios
|
||||||
|
else if opt ? type && opt._type == "option" then
|
||||||
|
prio
|
||||||
|
else
|
||||||
|
getPrios { options = opt; }
|
||||||
|
) filteredOptions;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit getPrios;
|
||||||
|
}
|
||||||
24
lib/values/flake-module.nix
Normal file
24
lib/values/flake-module.nix
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{ self, inputs, ... }:
|
||||||
|
let
|
||||||
|
inputOverrides = builtins.concatStringsSep " " (
|
||||||
|
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
|
||||||
|
);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
perSystem =
|
||||||
|
{ pkgs, system, ... }:
|
||||||
|
{
|
||||||
|
checks = {
|
||||||
|
lib-values-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
|
||||||
|
export HOME="$(realpath .)"
|
||||||
|
|
||||||
|
nix-unit --eval-store "$HOME" \
|
||||||
|
--extra-experimental-features flakes \
|
||||||
|
${inputOverrides} \
|
||||||
|
--flake ${self}#legacyPackages.${system}.evalTests-inventory
|
||||||
|
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
211
lib/values/test.nix
Normal file
211
lib/values/test.nix
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# tests for the nixos options to jsonschema converter
|
||||||
|
# run these tests via `nix-unit ./test.nix`
|
||||||
|
{
|
||||||
|
lib ? (import <nixpkgs> { }).lib,
|
||||||
|
slib ? (import ./. { inherit lib; }),
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
eval =
|
||||||
|
modules:
|
||||||
|
let
|
||||||
|
evaledConfig = lib.evalModules {
|
||||||
|
inherit modules;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
evaledConfig;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
test_default = {
|
||||||
|
expr = slib.getPrios {
|
||||||
|
options =
|
||||||
|
(eval [
|
||||||
|
{
|
||||||
|
options.foo.bar = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
description = "Test Description";
|
||||||
|
default = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
]).options;
|
||||||
|
};
|
||||||
|
expected = {
|
||||||
|
foo.bar = {
|
||||||
|
__prio = 1500;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
test_no_default = {
|
||||||
|
expr = slib.getPrios {
|
||||||
|
options =
|
||||||
|
(eval [
|
||||||
|
{
|
||||||
|
options.foo.bar = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
]).options;
|
||||||
|
};
|
||||||
|
expected = {
|
||||||
|
foo.bar = {
|
||||||
|
__prio = 9999;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test_submodule = {
|
||||||
|
expr = slib.getPrios {
|
||||||
|
options =
|
||||||
|
(eval [
|
||||||
|
{
|
||||||
|
options.foo = lib.mkOption {
|
||||||
|
type = lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
bar = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
]).options;
|
||||||
|
};
|
||||||
|
expected = {
|
||||||
|
foo = {
|
||||||
|
# Prio of the submodule itself
|
||||||
|
__prio = 9999;
|
||||||
|
|
||||||
|
# Prio of the bar option within the submodule
|
||||||
|
bar.__prio = 9999;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# TODO(@hsjobeki): Cover this edge case
|
||||||
|
# test_freeform =
|
||||||
|
# let
|
||||||
|
# evaluated = (
|
||||||
|
# eval [
|
||||||
|
# {
|
||||||
|
# freeformType = with lib.types; attrsOf (int);
|
||||||
|
# options = {
|
||||||
|
# foo = lib.mkOption {
|
||||||
|
# type = lib.types.int;
|
||||||
|
# default = 0;
|
||||||
|
# };
|
||||||
|
# };
|
||||||
|
# }
|
||||||
|
# {
|
||||||
|
# bar = lib.mkForce 123;
|
||||||
|
# baz = 1;
|
||||||
|
# }
|
||||||
|
# {
|
||||||
|
# bar = 10;
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
# );
|
||||||
|
# in
|
||||||
|
# {
|
||||||
|
# inherit evaluated;
|
||||||
|
# expr = slib.getPrios {
|
||||||
|
# options = evaluated.options;
|
||||||
|
# };
|
||||||
|
# expected = {
|
||||||
|
# };
|
||||||
|
# };
|
||||||
|
|
||||||
|
test_attrsOf_submodule =
|
||||||
|
let
|
||||||
|
evaluated = eval [
|
||||||
|
{
|
||||||
|
options.foo = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
bar = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
config.foo = {
|
||||||
|
"nested" = {
|
||||||
|
"bar" = 2; # <- 100 prio ?
|
||||||
|
};
|
||||||
|
"other" = {
|
||||||
|
"bar" = lib.mkForce 2; # <- 50 prio ?
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
expr = slib.getPrios { options = evaluated.options; };
|
||||||
|
expected = {
|
||||||
|
foo.__prio = 100;
|
||||||
|
|
||||||
|
foo.nested.__prio = 100;
|
||||||
|
foo.other.__prio = 100;
|
||||||
|
|
||||||
|
foo.nested.bar.__prio = 100;
|
||||||
|
foo.other.bar.__prio = 50;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
test_attrsOf_attrsOf_submodule =
|
||||||
|
let
|
||||||
|
evaluated = eval [
|
||||||
|
{
|
||||||
|
options.foo = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
bar = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
config.foo = {
|
||||||
|
a.b = {
|
||||||
|
bar = 1;
|
||||||
|
};
|
||||||
|
a.c = {
|
||||||
|
bar = 1;
|
||||||
|
};
|
||||||
|
x.y = {
|
||||||
|
bar = 1;
|
||||||
|
};
|
||||||
|
x.z = {
|
||||||
|
bar = 1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit evaluated;
|
||||||
|
expr = slib.getPrios { options = evaluated.options; };
|
||||||
|
expected = {
|
||||||
|
foo.__prio = 100;
|
||||||
|
|
||||||
|
# Sub A
|
||||||
|
foo.a.__prio = 100;
|
||||||
|
# a.b doesnt have a prio
|
||||||
|
# a.c doesnt have a prio
|
||||||
|
foo.a.b.bar.__prio = 100;
|
||||||
|
foo.a.c.bar.__prio = 100;
|
||||||
|
|
||||||
|
# Sub X
|
||||||
|
foo.x.__prio = 100;
|
||||||
|
# x.y doesnt have a prio
|
||||||
|
# x.z doesnt have a prio
|
||||||
|
foo.x.y.bar.__prio = 100;
|
||||||
|
foo.x.z.bar.__prio = 100;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -119,6 +119,37 @@ def load_inventory_json(
|
|||||||
return inventory
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
|
def patch(d: dict[str, Any], path: str, content: Any) -> None:
|
||||||
|
"""
|
||||||
|
Update the value at a specific dot-separated path in a nested dictionary.
|
||||||
|
|
||||||
|
:param d: The dictionary to update.
|
||||||
|
:param path: The dot-separated path to the key (e.g., 'foo.bar').
|
||||||
|
:param content: The new value to set.
|
||||||
|
"""
|
||||||
|
keys = path.split(".")
|
||||||
|
current = d
|
||||||
|
for key in keys[:-1]:
|
||||||
|
current = current.setdefault(key, {})
|
||||||
|
current[keys[-1]] = content
|
||||||
|
|
||||||
|
|
||||||
|
@API.register
|
||||||
|
def patch_inventory_with(base_dir: Path, section: str, content: dict[str, Any]) -> None:
|
||||||
|
inventory_file = get_path(base_dir)
|
||||||
|
|
||||||
|
curr_inventory = {}
|
||||||
|
with inventory_file.open("r") as f:
|
||||||
|
curr_inventory = json.load(f)
|
||||||
|
|
||||||
|
patch(curr_inventory, section, content)
|
||||||
|
|
||||||
|
with inventory_file.open("w") as f:
|
||||||
|
json.dump(curr_inventory, f, indent=2)
|
||||||
|
|
||||||
|
commit_file(inventory_file, base_dir, commit_message=f"inventory.{section}: Update")
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def set_inventory(
|
def set_inventory(
|
||||||
inventory: Inventory | dict[str, Any], flake_dir: str | Path, message: str
|
inventory: Inventory | dict[str, Any], flake_dir: str | Path, message: str
|
||||||
@@ -129,6 +160,19 @@ def set_inventory(
|
|||||||
"""
|
"""
|
||||||
inventory_file = get_path(flake_dir)
|
inventory_file = get_path(flake_dir)
|
||||||
|
|
||||||
|
# Filter out modules not set via UI.
|
||||||
|
# It is not possible to set modules from "/nix/store" via the UI
|
||||||
|
modules = {}
|
||||||
|
filtered_modules = lambda m: {
|
||||||
|
key: value for key, value in m.items() if "/nix/store" not in value
|
||||||
|
}
|
||||||
|
if isinstance(inventory, dict):
|
||||||
|
modules = filtered_modules(inventory.get("modules", {})) # type: ignore
|
||||||
|
inventory["modules"] = modules
|
||||||
|
else:
|
||||||
|
modules = filtered_modules(inventory.modules) # type: ignore
|
||||||
|
inventory.modules = modules
|
||||||
|
|
||||||
with inventory_file.open("w") as f:
|
with inventory_file.open("w") as f:
|
||||||
if isinstance(inventory, Inventory):
|
if isinstance(inventory, Inventory):
|
||||||
json.dump(dataclass_to_dict(inventory), f, indent=2)
|
json.dump(dataclass_to_dict(inventory), f, indent=2)
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ from typing import Literal
|
|||||||
|
|
||||||
from clan_cli.api import API
|
from clan_cli.api import API
|
||||||
from clan_cli.api.modules import parse_frontmatter
|
from clan_cli.api.modules import parse_frontmatter
|
||||||
|
from clan_cli.api.serde import dataclass_to_dict
|
||||||
from clan_cli.cmd import run_no_stdout
|
from clan_cli.cmd import run_no_stdout
|
||||||
from clan_cli.completions import add_dynamic_completer, complete_tags
|
from clan_cli.completions import add_dynamic_completer, complete_tags
|
||||||
from clan_cli.dirs import specific_machine_dir
|
from clan_cli.dirs import specific_machine_dir
|
||||||
from clan_cli.errors import ClanCmdError, ClanError
|
from clan_cli.errors import ClanCmdError, ClanError
|
||||||
from clan_cli.inventory import Machine, load_inventory_eval, set_inventory
|
from clan_cli.inventory import (
|
||||||
|
Machine,
|
||||||
|
load_inventory_eval,
|
||||||
|
patch_inventory_with,
|
||||||
|
)
|
||||||
from clan_cli.machines.hardware import HardwareConfig
|
from clan_cli.machines.hardware import HardwareConfig
|
||||||
from clan_cli.nix import nix_eval, nix_shell
|
from clan_cli.nix import nix_eval, nix_shell
|
||||||
from clan_cli.tags import list_nixos_machines_by_tags
|
from clan_cli.tags import list_nixos_machines_by_tags
|
||||||
@@ -20,12 +25,10 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
def set_machine(flake_url: str | Path, machine_name: str, machine: Machine) -> None:
|
def set_machine(flake_url: Path, machine_name: str, machine: Machine) -> None:
|
||||||
inventory = load_inventory_eval(flake_url)
|
patch_inventory_with(
|
||||||
|
flake_url, f"machines.{machine_name}", dataclass_to_dict(machine)
|
||||||
inventory.machines[machine_name] = machine
|
)
|
||||||
|
|
||||||
set_inventory(inventory, flake_url, "machines: edit '{machine_name}'")
|
|
||||||
|
|
||||||
|
|
||||||
@API.register
|
@API.register
|
||||||
|
|||||||
36
pkgs/clan-cli/tests/test_patch_inventory.py
Normal file
36
pkgs/clan-cli/tests/test_patch_inventory.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Functions to test
|
||||||
|
from clan_cli.inventory import patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_nested() -> None:
|
||||||
|
orig = {"a": 1, "b": {"a": 2.1, "b": 2.2}, "c": 3}
|
||||||
|
|
||||||
|
patch(orig, "b.b", "foo")
|
||||||
|
|
||||||
|
# Should only update the nested value
|
||||||
|
assert orig == {"a": 1, "b": {"a": 2.1, "b": "foo"}, "c": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_nested_dict() -> None:
|
||||||
|
orig = {"a": 1, "b": {"a": 2.1, "b": 2.2}, "c": 3}
|
||||||
|
|
||||||
|
# This should update the whole "b" dict
|
||||||
|
# Which also removes all other keys
|
||||||
|
patch(orig, "b", {"b": "foo"})
|
||||||
|
|
||||||
|
# Should only update the nested value
|
||||||
|
assert orig == {"a": 1, "b": {"b": "foo"}, "c": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_missing_paths() -> None:
|
||||||
|
orig = {"a": 1}
|
||||||
|
|
||||||
|
patch(orig, "b.c", "foo")
|
||||||
|
|
||||||
|
# Should only update the nested value
|
||||||
|
assert orig == {"a": 1, "b": {"c": "foo"}}
|
||||||
|
|
||||||
|
orig = {}
|
||||||
|
patch(orig, "a.b.c", "foo")
|
||||||
|
|
||||||
|
assert orig == {"a": {"b": {"c": "foo"}}}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { QueryClient } from "@tanstack/query-core";
|
|
||||||
import { get_inventory } from "./inventory";
|
|
||||||
|
|
||||||
export const instance_name = (machine_name: string) =>
|
|
||||||
`${machine_name}-single-disk` as const;
|
|
||||||
|
|
||||||
export async function set_single_disk_id(
|
|
||||||
client: QueryClient,
|
|
||||||
base_path: string,
|
|
||||||
machine_name: string,
|
|
||||||
disk_id: string,
|
|
||||||
) {
|
|
||||||
const r = await get_inventory(client, base_path);
|
|
||||||
if (r.status === "error") {
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
if (!r.data.services) {
|
|
||||||
return new Error("No services found in inventory");
|
|
||||||
}
|
|
||||||
const inventory = r.data;
|
|
||||||
inventory.services = inventory.services || {};
|
|
||||||
inventory.services["single-disk"] = inventory.services["single-disk"] || {};
|
|
||||||
|
|
||||||
inventory.services["single-disk"][instance_name(machine_name)] = {
|
|
||||||
meta: {
|
|
||||||
name: instance_name(machine_name),
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
default: {
|
|
||||||
machines: [machine_name],
|
|
||||||
config: {
|
|
||||||
device: `/dev/disk/by-id/${disk_id}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { callApi, ClanService, SuccessData, SuccessQuery } from "@/src/api";
|
import { callApi, ClanService, SuccessData, SuccessQuery } from "@/src/api";
|
||||||
import { set_single_disk_id } from "@/src/api/disk";
|
|
||||||
import { get_iwd_service } from "@/src/api/wifi";
|
import { get_iwd_service } from "@/src/api/wifi";
|
||||||
import { activeURI } from "@/src/App";
|
import { activeURI } from "@/src/App";
|
||||||
import { BackButton } from "@/src/components/BackButton";
|
import { BackButton } from "@/src/components/BackButton";
|
||||||
import { Button } from "@/src/components/button";
|
import { Button } from "@/src/components/button";
|
||||||
import { FileInput } from "@/src/components/FileInput";
|
import { FileInput } from "@/src/components/FileInput";
|
||||||
import Icon from "@/src/components/icon";
|
import Icon from "@/src/components/icon";
|
||||||
|
import { RndThumbnail } from "@/src/components/noiseThumbnail";
|
||||||
import { SelectInput } from "@/src/components/SelectInput";
|
import { SelectInput } from "@/src/components/SelectInput";
|
||||||
import { TextInput } from "@/src/components/TextInput";
|
import { TextInput } from "@/src/components/TextInput";
|
||||||
import { selectSshKeys } from "@/src/hooks";
|
import { selectSshKeys } from "@/src/hooks";
|
||||||
@@ -54,18 +54,6 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
|
|
||||||
const [confirmDisk, setConfirmDisk] = createSignal(!hasDisk());
|
const [confirmDisk, setConfirmDisk] = createSignal(!hasDisk());
|
||||||
|
|
||||||
const hwInfoQuery = createQuery(() => ({
|
|
||||||
queryKey: [curr, "machine", name, "show_machine_hardware_config"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await callApi("show_machine_hardware_config", {
|
|
||||||
clan_dir: curr,
|
|
||||||
machine_name: name,
|
|
||||||
});
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data === "NIXOS_FACTER";
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleInstall = async (values: InstallForm) => {
|
const handleInstall = async (values: InstallForm) => {
|
||||||
console.log("Installing", values);
|
console.log("Installing", values);
|
||||||
const curr_uri = activeURI();
|
const curr_uri = activeURI();
|
||||||
@@ -98,7 +86,6 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
toast.success("Machine installed successfully");
|
toast.success("Machine installed successfully");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handleDiskConfirm = async (e: Event) => {
|
const handleDiskConfirm = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -108,19 +95,6 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
if (!curr_uri || !disk_id || !props.name) {
|
if (!curr_uri || !disk_id || !props.name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await set_single_disk_id(
|
|
||||||
queryClient,
|
|
||||||
curr_uri,
|
|
||||||
props.name,
|
|
||||||
disk_id,
|
|
||||||
);
|
|
||||||
if (!r) {
|
|
||||||
toast.success("Disk set successfully");
|
|
||||||
setConfirmDisk(true);
|
|
||||||
} else {
|
|
||||||
toast.error("Failed to set disk");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateReport = async (e: Event) => {
|
const generateReport = async (e: Event) => {
|
||||||
@@ -141,7 +115,7 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.dismiss(loading_toast);
|
toast.dismiss(loading_toast);
|
||||||
hwInfoQuery.refetch();
|
// TODO: refresh the machine details
|
||||||
|
|
||||||
if (r.status === "error") {
|
if (r.status === "error") {
|
||||||
toast.error(`Failed to generate report. ${r.errors[0].message}`);
|
toast.error(`Failed to generate report. ${r.errors[0].message}`);
|
||||||
@@ -164,81 +138,36 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="text-lg font-semibold">Hardware detection</div>
|
<div class="text-lg font-semibold">Hardware detection</div>
|
||||||
|
|
||||||
<div class="flex justify-between py-4">
|
<div class="flex justify-between py-4">
|
||||||
<Switch>
|
|
||||||
<Match when={hwInfoQuery.isLoading}>
|
|
||||||
<span class="loading loading-lg"></span>
|
|
||||||
</Match>
|
|
||||||
<Match when={hwInfoQuery.isFetched}>
|
|
||||||
<Show
|
|
||||||
when={hwInfoQuery.data}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<span class="flex align-middle">
|
|
||||||
<span class="material-icons text-inherit">close</span>
|
|
||||||
Not Detected
|
|
||||||
</span>
|
|
||||||
<div class="text-neutral">
|
|
||||||
This might still work, but it is recommended to generate
|
|
||||||
a hardware report.
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="flex align-middle">
|
|
||||||
<span class="material-icons text-inherit">check</span>
|
|
||||||
Detected
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<div class="">
|
<div class="">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
size="s"
|
size="s"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onclick={generateReport}
|
onclick={generateReport}
|
||||||
|
endIcon={<Icon icon="Report" />}
|
||||||
>
|
>
|
||||||
<span class="material-icons">manage_search</span>
|
Run hardware Report
|
||||||
Generate report
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-semibold">Disk schema</div>
|
||||||
|
<div class="flex justify-between py-4">
|
||||||
|
<div class="">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="s"
|
||||||
|
class="w-full"
|
||||||
|
onclick={generateReport}
|
||||||
|
endIcon={<Icon icon="Flash" />}
|
||||||
|
>
|
||||||
|
Select disk Schema
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field name="disk">
|
<Field name="disk">{(field, fieldProps) => "disk"}</Field>
|
||||||
{(field, fieldProps) => (
|
|
||||||
<SelectInput
|
|
||||||
formStore={formStore}
|
|
||||||
selectProps={{
|
|
||||||
...fieldProps,
|
|
||||||
// @ts-expect-error: disabled is supported by htmlSelect
|
|
||||||
disabled: confirmDisk(),
|
|
||||||
}}
|
|
||||||
label="Remote Disk to use"
|
|
||||||
value={String(field.value)}
|
|
||||||
error={field.error}
|
|
||||||
required
|
|
||||||
options={
|
|
||||||
<>
|
|
||||||
<option disabled>{diskPlaceholder}</option>
|
|
||||||
<For each={props.disks}>
|
|
||||||
{(dev) => (
|
|
||||||
<option value={dev.name}>
|
|
||||||
{dev.name}
|
|
||||||
{" -- "}
|
|
||||||
{dev.size}
|
|
||||||
{"bytes @"}
|
|
||||||
{props.targetHost?.split("@")?.[1]}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<div role="alert" class="alert my-4">
|
<div role="alert" class="alert my-4">
|
||||||
<span class="material-icons">info</span>
|
<span class="material-icons">info</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -260,16 +189,16 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
class="btn btn-primary btn-wide"
|
class="btn btn-primary btn-wide"
|
||||||
onClick={handleDiskConfirm}
|
onClick={handleDiskConfirm}
|
||||||
disabled={!hasDisk()}
|
disabled={!hasDisk()}
|
||||||
startIcon={<Icon icon="Flash" />}
|
endIcon={<Icon icon="Flash" />}
|
||||||
>
|
>
|
||||||
Confirm Disk
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
class="w-full"
|
class="w-full"
|
||||||
type="submit"
|
type="submit"
|
||||||
startIcon={<Icon icon="Flash" />}
|
endIcon={<Icon icon="Flash" />}
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
@@ -291,10 +220,6 @@ const InstallMachine = (props: InstallMachineProps) => {
|
|||||||
|
|
||||||
interface MachineDetailsProps {
|
interface MachineDetailsProps {
|
||||||
initialData: MachineData;
|
initialData: MachineData;
|
||||||
modules: {
|
|
||||||
name: string;
|
|
||||||
component: JSXElement;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
const MachineForm = (props: MachineDetailsProps) => {
|
const MachineForm = (props: MachineDetailsProps) => {
|
||||||
const [formStore, { Form, Field }] =
|
const [formStore, { Form, Field }] =
|
||||||
@@ -308,52 +233,9 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
const machineName = () =>
|
const machineName = () =>
|
||||||
getValue(formStore, "machine.name") || props.initialData.machine.name;
|
getValue(formStore, "machine.name") || props.initialData.machine.name;
|
||||||
|
|
||||||
const onlineStatusQuery = createQuery(() => ({
|
|
||||||
queryKey: [activeURI(), "machine", targetHost(), "check_machine_online"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const curr = activeURI();
|
|
||||||
if (curr) {
|
|
||||||
const result = await callApi("check_machine_online", {
|
|
||||||
flake_url: curr,
|
|
||||||
machine_name: machineName(),
|
|
||||||
opts: {
|
|
||||||
keyfile: sshKey()?.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// refetchInterval: 10_000, // 10 seconds
|
|
||||||
}));
|
|
||||||
|
|
||||||
const online = () => onlineStatusQuery.data === "Online";
|
|
||||||
|
|
||||||
const remoteDiskQuery = createQuery(() => ({
|
|
||||||
queryKey: [
|
|
||||||
activeURI(),
|
|
||||||
"machine",
|
|
||||||
machineName(),
|
|
||||||
targetHost(),
|
|
||||||
"show_block_devices",
|
|
||||||
],
|
|
||||||
enabled: online(),
|
|
||||||
queryFn: async () => {
|
|
||||||
const curr = activeURI();
|
|
||||||
if (curr) {
|
|
||||||
const result = await callApi("show_block_devices", {
|
|
||||||
options: {
|
|
||||||
hostname: targetHost(),
|
|
||||||
keyfile: sshKey()?.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleSubmit = async (values: MachineFormInterface) => {
|
const handleSubmit = async (values: MachineFormInterface) => {
|
||||||
|
console.log("submitting", values);
|
||||||
|
|
||||||
const curr_uri = activeURI();
|
const curr_uri = activeURI();
|
||||||
if (!curr_uri) {
|
if (!curr_uri) {
|
||||||
return;
|
return;
|
||||||
@@ -426,27 +308,20 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
<figure>
|
<figure>
|
||||||
<div
|
<div
|
||||||
class="avatar placeholder"
|
class="avatar placeholder"
|
||||||
classList={{
|
classList={
|
||||||
online: onlineStatusQuery.data === "Online",
|
{
|
||||||
offline: onlineStatusQuery.data === "Offline",
|
// online: onlineStatusQuery.data === "Online",
|
||||||
}}
|
// offline: onlineStatusQuery.data === "Offline",
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div class="w-24 rounded-full bg-neutral text-neutral-content">
|
<div class="w-32 rounded-lg border p-2 bg-def-4 border-inv-3">
|
||||||
<Show
|
<RndThumbnail name={machineName()} />
|
||||||
when={onlineStatusQuery.isFetching}
|
|
||||||
fallback={<span class="material-icons text-4xl">devices</span>}
|
|
||||||
>
|
|
||||||
<span class="loading loading-bars loading-sm justify-self-end"></span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="text-xl text-primary-800">General</span>
|
<span class="text-xl text-primary-800">General</span>
|
||||||
{/*
|
|
||||||
<Field name="machine.tags" type="string[]">
|
|
||||||
{(field, props) => field.value}
|
|
||||||
</Field> */}
|
|
||||||
|
|
||||||
<Field name="machine.name">
|
<Field name="machine.name">
|
||||||
{(field, props) => (
|
{(field, props) => (
|
||||||
@@ -461,6 +336,20 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field name="machine.tags" type="string[]">
|
||||||
|
{(field, props) => (
|
||||||
|
<For each={field.value}>
|
||||||
|
{(tag) => (
|
||||||
|
<label class="p-1">
|
||||||
|
Tags
|
||||||
|
<span class="mx-2 rounded-full px-3 py-1 bg-inv-4 fg-inv-1 w-fit">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
<Field name="machine.description">
|
<Field name="machine.description">
|
||||||
{(field, props) => (
|
{(field, props) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -474,6 +363,16 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field name="hw_config">
|
||||||
|
{(field, props) => (
|
||||||
|
<label>Hardware report: {field.value || "None"}</label>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field name="disk_schema">
|
||||||
|
{(field, props) => (
|
||||||
|
<span>Disk schema: {field.value || "None"}</span>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div class="collapse collapse-arrow" tabindex="0">
|
<div class="collapse collapse-arrow" tabindex="0">
|
||||||
<input type="checkbox" />
|
<input type="checkbox" />
|
||||||
@@ -542,15 +441,6 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<For each={props.modules}>
|
|
||||||
{(module) => (
|
|
||||||
<>
|
|
||||||
<div class="divider"></div>
|
|
||||||
<span class="text-xl text-primary-800">{module.name}</span>
|
|
||||||
{module.component}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<span class="text-xl text-primary-800">Actions</span>
|
<span class="text-xl text-primary-800">Actions</span>
|
||||||
@@ -581,7 +471,7 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
name={machineName()}
|
name={machineName()}
|
||||||
sshKey={sshKey()}
|
sshKey={sshKey()}
|
||||||
targetHost={getValue(formStore, "machine.deploy.targetHost")}
|
targetHost={getValue(formStore, "machine.deploy.targetHost")}
|
||||||
disks={remoteDiskQuery.data?.blockdevices || []}
|
disks={[]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -593,7 +483,7 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
<div class="tooltip w-fit" data-tip="Machine must be online">
|
<div class="tooltip w-fit" data-tip="Machine must be online">
|
||||||
<Button
|
<Button
|
||||||
class="w-full"
|
class="w-full"
|
||||||
disabled={!online()}
|
// disabled={!online()}
|
||||||
onClick={() => handleUpdate()}
|
onClick={() => handleUpdate()}
|
||||||
endIcon={<Icon icon="Update" />}
|
endIcon={<Icon icon="Update" />}
|
||||||
>
|
>
|
||||||
@@ -606,8 +496,6 @@ const MachineForm = (props: MachineDetailsProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type WifiData = ClanService<"iwd">;
|
|
||||||
|
|
||||||
export const MachineDetails = () => {
|
export const MachineDetails = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const genericQuery = createQuery(() => ({
|
const genericQuery = createQuery(() => ({
|
||||||
@@ -630,20 +518,6 @@ export const MachineDetails = () => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const wifiQuery = createQuery(() => ({
|
|
||||||
queryKey: [activeURI(), "machine", params.id, "get_iwd_service"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const curr = activeURI();
|
|
||||||
if (curr) {
|
|
||||||
const result = await get_iwd_service(curr, params.id);
|
|
||||||
if (!result) throw new Error("Failed to fetch data");
|
|
||||||
return Object.entries(result?.config?.networks || {}).map(
|
|
||||||
([name, value]) => ({ name, ssid: value.ssid }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
@@ -653,36 +527,7 @@ export const MachineDetails = () => {
|
|||||||
>
|
>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<>
|
<>
|
||||||
<MachineForm
|
<MachineForm initialData={data()} />
|
||||||
initialData={data()}
|
|
||||||
modules={[
|
|
||||||
{
|
|
||||||
component: (
|
|
||||||
<Show
|
|
||||||
when={!wifiQuery.isLoading}
|
|
||||||
fallback={
|
|
||||||
<div>
|
|
||||||
<span class="loading loading-lg"></span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch>
|
|
||||||
<Match when={wifiQuery.data}>
|
|
||||||
{(d) => (
|
|
||||||
<WifiModule
|
|
||||||
initialData={d()}
|
|
||||||
base_url={activeURI() || ""}
|
|
||||||
machine_name={data().machine.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
|
||||||
),
|
|
||||||
name: "Wifi",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { type Component, createSignal, For, Match, Switch } from "solid-js";
|
import {
|
||||||
|
type Component,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Match,
|
||||||
|
Show,
|
||||||
|
Switch,
|
||||||
|
} from "solid-js";
|
||||||
import { activeURI } from "@/src/App";
|
import { activeURI } from "@/src/App";
|
||||||
import { callApi, OperationResponse } from "@/src/api";
|
import { callApi, OperationResponse } from "@/src/api";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
@@ -75,9 +82,10 @@ export const MachineListView: Component = () => {
|
|||||||
startIcon={<Icon icon="Plus" />}
|
startIcon={<Icon icon="Plus" />}
|
||||||
></Button>
|
></Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* <Show when={filter()}> */}
|
||||||
<div class="my-1 flex w-full gap-2 p-2">
|
<div class="my-1 flex w-full gap-2 p-2">
|
||||||
<div class="h-6 w-6 p-1">
|
<div class="h-6 w-6 p-1">
|
||||||
<Icon icon="Info" />
|
<Icon icon="Filter" />
|
||||||
</div>
|
</div>
|
||||||
<For each={filter().tags.sort()}>
|
<For each={filter().tags.sort()}>
|
||||||
{(tag) => (
|
{(tag) => (
|
||||||
@@ -99,6 +107,7 @@ export const MachineListView: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
{/* </Show> */}
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={inventoryQuery.isLoading}>
|
<Match when={inventoryQuery.isLoading}>
|
||||||
{/* Loading skeleton */}
|
{/* Loading skeleton */}
|
||||||
|
|||||||
Reference in New Issue
Block a user