Merge remote-tracking branch 'origin/main' into rework-installation
This commit is contained in:
@@ -1,92 +0,0 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
from dataclasses import fields, is_dataclass
|
||||
from pathlib import Path
|
||||
from types import UnionType
|
||||
from typing import Any, get_args
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("WebKit", "6.0")
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sanitize_string(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
|
||||
def dataclass_to_dict(obj: Any) -> Any:
|
||||
"""
|
||||
Utility function to convert dataclasses to dictionaries
|
||||
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
|
||||
|
||||
It does NOT convert member functions.
|
||||
"""
|
||||
if dataclasses.is_dataclass(obj):
|
||||
return {
|
||||
sanitize_string(k): dataclass_to_dict(v)
|
||||
for k, v in dataclasses.asdict(obj).items()
|
||||
}
|
||||
elif isinstance(obj, list | tuple):
|
||||
return [dataclass_to_dict(item) for item in obj]
|
||||
elif isinstance(obj, dict):
|
||||
return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, Path):
|
||||
return str(obj)
|
||||
elif isinstance(obj, str):
|
||||
return sanitize_string(obj)
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
def is_union_type(type_hint: type) -> bool:
|
||||
return type(type_hint) is UnionType
|
||||
|
||||
|
||||
def get_inner_type(type_hint: type) -> type:
|
||||
if is_union_type(type_hint):
|
||||
# Return the first non-None type
|
||||
return next(t for t in get_args(type_hint) if t is not type(None))
|
||||
return type_hint
|
||||
|
||||
|
||||
def from_dict(t: type, data: dict[str, Any] | None) -> Any:
|
||||
"""
|
||||
Dynamically instantiate a data class from a dictionary, handling nested data classes.
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Attempt to create an instance of the data_class
|
||||
field_values = {}
|
||||
for field in fields(t):
|
||||
field_value = data.get(field.name)
|
||||
field_type = get_inner_type(field.type)
|
||||
if field_value is not None:
|
||||
# If the field is another dataclass, recursively instantiate it
|
||||
if is_dataclass(field_type):
|
||||
field_value = from_dict(field_type, field_value)
|
||||
elif isinstance(field_type, Path | str) and isinstance(
|
||||
field_value, str
|
||||
):
|
||||
field_value = (
|
||||
Path(field_value) if field_type == Path else field_value
|
||||
)
|
||||
|
||||
if (
|
||||
field.default is not dataclasses.MISSING
|
||||
or field.default_factory is not dataclasses.MISSING
|
||||
):
|
||||
# Field has a default value. We cannot set the value to None
|
||||
if field_value is not None:
|
||||
field_values[field.name] = field_value
|
||||
else:
|
||||
field_values[field.name] = field_value
|
||||
|
||||
return t(**field_values)
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
print(f"Failed to instantiate {t.__name__}: {e}")
|
||||
return None
|
||||
@@ -94,7 +94,14 @@ python3.pkgs.buildPythonApplication rec {
|
||||
# that all necessary dependencies are consistently available both
|
||||
# at build time and runtime,
|
||||
buildInputs = allPythonDeps ++ runtimeDependencies;
|
||||
propagatedBuildInputs = allPythonDeps ++ runtimeDependencies;
|
||||
propagatedBuildInputs =
|
||||
allPythonDeps
|
||||
++ runtimeDependencies
|
||||
++ [
|
||||
|
||||
# TODO: see postFixup clan-cli/default.nix:L188
|
||||
clan-cli.propagatedBuildInputs
|
||||
];
|
||||
|
||||
# also re-expose dependencies so we test them in CI
|
||||
passthru = {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
else
|
||||
{
|
||||
devShells.clan-app = pkgs.callPackage ./shell.nix {
|
||||
inherit (config.packages) clan-app webview-ui;
|
||||
inherit (config.packages) clan-app;
|
||||
inherit self';
|
||||
};
|
||||
packages.clan-app = pkgs.python3.pkgs.callPackage ./default.nix {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
python3,
|
||||
gtk4,
|
||||
libadwaita,
|
||||
webview-ui,
|
||||
self',
|
||||
}:
|
||||
|
||||
@@ -29,7 +28,7 @@ let
|
||||
]);
|
||||
in
|
||||
mkShell {
|
||||
inherit (clan-app) nativeBuildInputs;
|
||||
inherit (clan-app) nativeBuildInputs propagatedBuildInputs;
|
||||
|
||||
inputsFrom = [ self'.devShells.default ];
|
||||
|
||||
@@ -67,8 +66,5 @@ mkShell {
|
||||
export XDG_DATA_DIRS=${gtk4}/share/gsettings-schemas/gtk4-4.14.4:$XDG_DATA_DIRS
|
||||
export XDG_DATA_DIRS=${gsettings-desktop-schemas}/share/gsettings-schemas/gsettings-desktop-schemas-46.0:$XDG_DATA_DIRS
|
||||
|
||||
# Add the webview-ui to the .webui directory
|
||||
ln -nsf ${webview-ui}/lib/node_modules/@clan/webview-ui/dist/ ./clan_app/.webui
|
||||
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ from types import ModuleType
|
||||
# These imports are unused, but necessary for @API.register to run once.
|
||||
from clan_cli.api import directory, mdns_discovery, modules
|
||||
from clan_cli.arg_actions import AppendOptionAction
|
||||
from clan_cli.clan import show
|
||||
from clan_cli.clan import show, update
|
||||
|
||||
# API endpoints that are not used in the cli.
|
||||
__all__ = ["directory", "mdns_discovery", "modules"]
|
||||
__all__ = ["directory", "mdns_discovery", "modules", "update"]
|
||||
|
||||
from . import (
|
||||
backups,
|
||||
|
||||
@@ -1,141 +1,22 @@
|
||||
import dataclasses
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, fields, is_dataclass
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from inspect import Parameter, Signature, signature
|
||||
from pathlib import Path
|
||||
from types import UnionType
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
TypeVar,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from .serde import dataclass_to_dict, from_dict, sanitize_string
|
||||
|
||||
__all__ = ["from_dict", "dataclass_to_dict", "sanitize_string"]
|
||||
|
||||
from clan_cli.errors import ClanError
|
||||
|
||||
|
||||
def sanitize_string(s: str) -> str:
|
||||
# Using the native string sanitizer to handle all edge cases
|
||||
# Remove the outer quotes '"string"'
|
||||
return json.dumps(s)[1:-1]
|
||||
|
||||
|
||||
def dataclass_to_dict(obj: Any) -> Any:
|
||||
"""
|
||||
Utility function to convert dataclasses to dictionaries
|
||||
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
|
||||
|
||||
It does NOT convert member functions.
|
||||
"""
|
||||
if is_dataclass(obj):
|
||||
return {
|
||||
# Use either the original name or name
|
||||
sanitize_string(
|
||||
field.metadata.get("original_name", field.name)
|
||||
): dataclass_to_dict(getattr(obj, field.name))
|
||||
for field in fields(obj) # type: ignore
|
||||
}
|
||||
elif isinstance(obj, list | tuple):
|
||||
return [dataclass_to_dict(item) for item in obj]
|
||||
elif isinstance(obj, dict):
|
||||
return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, Path):
|
||||
return sanitize_string(str(obj))
|
||||
elif isinstance(obj, str):
|
||||
return sanitize_string(obj)
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
def is_union_type(type_hint: type) -> bool:
|
||||
return type(type_hint) is UnionType
|
||||
|
||||
|
||||
def get_inner_type(type_hint: type) -> type:
|
||||
if is_union_type(type_hint):
|
||||
# Return the first non-None type
|
||||
return next(t for t in get_args(type_hint) if t is not type(None))
|
||||
return type_hint
|
||||
|
||||
|
||||
def get_second_type(type_hint: type[dict]) -> type:
|
||||
"""
|
||||
Get the value type of a dictionary type hint
|
||||
"""
|
||||
args = get_args(type_hint)
|
||||
if len(args) == 2:
|
||||
# Return the second argument, which should be the value type (Machine)
|
||||
return args[1]
|
||||
|
||||
raise ValueError(f"Invalid type hint for dict: {type_hint}")
|
||||
|
||||
|
||||
def from_dict(t: type, data: dict[str, Any] | None) -> Any:
|
||||
"""
|
||||
Dynamically instantiate a data class from a dictionary, handling nested data classes.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Attempt to create an instance of the data_class
|
||||
field_values = {}
|
||||
for field in fields(t):
|
||||
original_name = field.metadata.get("original_name", field.name)
|
||||
|
||||
field_value = data.get(original_name)
|
||||
|
||||
field_type = get_inner_type(field.type) # type: ignore
|
||||
|
||||
if original_name in data:
|
||||
# If the field is another dataclass, recursively instantiate it
|
||||
if is_dataclass(field_type):
|
||||
field_value = from_dict(field_type, field_value)
|
||||
elif isinstance(field_type, Path | str) and isinstance(
|
||||
field_value, str
|
||||
):
|
||||
field_value = (
|
||||
Path(field_value) if field_type == Path else field_value
|
||||
)
|
||||
elif get_origin(field_type) is dict and isinstance(field_value, dict):
|
||||
# The field is a dictionary with a specific type
|
||||
inner_type = get_second_type(field_type)
|
||||
field_value = {
|
||||
k: from_dict(inner_type, v) for k, v in field_value.items()
|
||||
}
|
||||
elif get_origin is list and isinstance(field_value, list):
|
||||
# The field is a list with a specific type
|
||||
inner_type = get_args(field_type)[0]
|
||||
field_value = [from_dict(inner_type, v) for v in field_value]
|
||||
|
||||
# Set the value
|
||||
if (
|
||||
field.default is not dataclasses.MISSING
|
||||
or field.default_factory is not dataclasses.MISSING
|
||||
):
|
||||
# Fields with default value
|
||||
# a: Int = 1
|
||||
# b: list = Field(default_factory=list)
|
||||
if original_name in data or field_value is not None:
|
||||
field_values[field.name] = field_value
|
||||
else:
|
||||
# Fields without default value
|
||||
# a: Int
|
||||
field_values[field.name] = field_value
|
||||
|
||||
return t(**field_values)
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
print(f"Failed to instantiate {t.__name__}: {e} {data}")
|
||||
return None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
ResponseDataType = TypeVar("ResponseDataType")
|
||||
|
||||
106
pkgs/clan-cli/clan_cli/api/serde.py
Normal file
106
pkgs/clan-cli/clan_cli/api/serde.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
This module provides utility functions for serialization and deserialization of data classes.
|
||||
|
||||
Functions:
|
||||
- sanitize_string(s: str) -> str: Ensures a string is properly escaped for json serializing.
|
||||
- dataclass_to_dict(obj: Any) -> Any: Converts a data class and its nested data classes, lists, tuples, and dictionaries to dictionaries.
|
||||
- from_dict(t: type[T], data: Any) -> T: Dynamically instantiates a data class from a dictionary, constructing nested data classes, validates all required fields exist and have the expected type.
|
||||
|
||||
Classes:
|
||||
- TypeAdapter: A Pydantic type adapter for data classes.
|
||||
|
||||
Exceptions:
|
||||
- ValidationError: Raised when there is a validation error during deserialization.
|
||||
- ClanError: Raised when there is an error during serialization or deserialization.
|
||||
|
||||
Dependencies:
|
||||
- dataclasses: Provides the @dataclass decorator and related functions for creating data classes.
|
||||
- json: Provides functions for working with JSON data.
|
||||
- collections.abc: Provides abstract base classes for collections.
|
||||
- functools: Provides functions for working with higher-order functions and decorators.
|
||||
- inspect: Provides functions for inspecting live objects.
|
||||
- operator: Provides functions for working with operators.
|
||||
- pathlib: Provides classes for working with filesystem paths.
|
||||
- types: Provides functions for working with types.
|
||||
- typing: Provides support for type hints.
|
||||
- pydantic: A library for data validation and settings management.
|
||||
- pydantic_core: Core functionality for Pydantic.
|
||||
|
||||
Note: This module assumes the presence of other modules and classes such as `ClanError` and `ErrorDetails` from the `clan_cli.errors` module.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, fields, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from pydantic_core import ErrorDetails
|
||||
|
||||
from clan_cli.errors import ClanError
|
||||
|
||||
|
||||
def sanitize_string(s: str) -> str:
|
||||
# Using the native string sanitizer to handle all edge cases
|
||||
# Remove the outer quotes '"string"'
|
||||
return json.dumps(s)[1:-1]
|
||||
|
||||
|
||||
def dataclass_to_dict(obj: Any, *, use_alias: bool = True) -> Any:
|
||||
def _to_dict(obj: Any) -> Any:
|
||||
"""
|
||||
Utility function to convert dataclasses to dictionaries
|
||||
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
|
||||
|
||||
It does NOT convert member functions.
|
||||
"""
|
||||
if is_dataclass(obj):
|
||||
return {
|
||||
# Use either the original name or name
|
||||
sanitize_string(
|
||||
field.metadata.get("alias", field.name) if use_alias else field.name
|
||||
): _to_dict(getattr(obj, field.name))
|
||||
for field in fields(obj)
|
||||
if not field.name.startswith("_") # type: ignore
|
||||
}
|
||||
elif isinstance(obj, list | tuple):
|
||||
return [_to_dict(item) for item in obj]
|
||||
elif isinstance(obj, dict):
|
||||
return {sanitize_string(k): _to_dict(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, Path):
|
||||
return sanitize_string(str(obj))
|
||||
elif isinstance(obj, str):
|
||||
return sanitize_string(obj)
|
||||
else:
|
||||
return obj
|
||||
|
||||
return _to_dict(obj)
|
||||
|
||||
|
||||
T = TypeVar("T", bound=dataclass) # type: ignore
|
||||
|
||||
|
||||
def from_dict(t: type[T], data: Any) -> T:
|
||||
"""
|
||||
Dynamically instantiate a data class from a dictionary, handling nested data classes.
|
||||
We use dataclasses. But the deserialization logic of pydantic takes a lot of complexity.
|
||||
"""
|
||||
adapter = TypeAdapter(t)
|
||||
try:
|
||||
return adapter.validate_python(
|
||||
data,
|
||||
)
|
||||
except ValidationError as e:
|
||||
fst_error: ErrorDetails = e.errors()[0]
|
||||
if not fst_error:
|
||||
raise ClanError(msg=str(e))
|
||||
|
||||
msg = fst_error.get("msg")
|
||||
loc = fst_error.get("loc")
|
||||
field_path = "Unknown"
|
||||
if loc:
|
||||
field_path = str(loc)
|
||||
raise ClanError(msg=msg, location=f"{t!s}: {field_path}", description=str(e))
|
||||
@@ -74,7 +74,9 @@ def type_to_dict(t: Any, scope: str = "", type_map: dict[TypeVar, type] = {}) ->
|
||||
if dataclasses.is_dataclass(t):
|
||||
fields = dataclasses.fields(t)
|
||||
properties = {
|
||||
f.name: type_to_dict(f.type, f"{scope} {t.__name__}.{f.name}", type_map)
|
||||
f.metadata.get("alias", f.name): type_to_dict(
|
||||
f.type, f"{scope} {t.__name__}.{f.name}", type_map
|
||||
)
|
||||
for f in fields
|
||||
if not f.name.startswith("_")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..vms.inspect import VmConfig, inspect_vm
|
||||
|
||||
@dataclass
|
||||
class FlakeConfig:
|
||||
flake_url: str | Path
|
||||
flake_url: FlakeId
|
||||
flake_attr: str
|
||||
|
||||
clan_name: str
|
||||
@@ -89,7 +89,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
|
||||
meta = nix_metadata(flake_url)
|
||||
return FlakeConfig(
|
||||
vm=vm,
|
||||
flake_url=flake_url,
|
||||
flake_url=FlakeId(flake_url),
|
||||
clan_name=clan_name,
|
||||
flake_attr=machine_name,
|
||||
nar_hash=meta["locked"]["narHash"],
|
||||
|
||||
@@ -62,7 +62,7 @@ def list_history() -> list[HistoryEntry]:
|
||||
|
||||
def new_history_entry(url: str, machine: str) -> HistoryEntry:
|
||||
flake = inspect_flake(url, machine)
|
||||
flake.flake_url = str(flake.flake_url)
|
||||
flake.flake_url = flake.flake_url
|
||||
return HistoryEntry(
|
||||
flake=flake,
|
||||
last_used=datetime.datetime.now().isoformat(),
|
||||
|
||||
@@ -16,7 +16,7 @@ def update_history() -> list[HistoryEntry]:
|
||||
|
||||
for entry in logs:
|
||||
try:
|
||||
meta = nix_metadata(entry.flake.flake_url)
|
||||
meta = nix_metadata(str(entry.flake.flake_url))
|
||||
except ClanCmdError as e:
|
||||
print(f"Failed to update {entry.flake.flake_url}: {e}")
|
||||
continue
|
||||
@@ -31,7 +31,7 @@ def update_history() -> list[HistoryEntry]:
|
||||
machine_name=entry.flake.flake_attr,
|
||||
)
|
||||
flake = inspect_flake(uri.get_url(), uri.machine_name)
|
||||
flake.flake_url = str(flake.flake_url)
|
||||
flake.flake_url = flake.flake_url
|
||||
entry = HistoryEntry(
|
||||
flake=flake, last_used=datetime.datetime.now().isoformat()
|
||||
)
|
||||
|
||||
@@ -153,7 +153,7 @@ class ServiceSingleDisk:
|
||||
class Service:
|
||||
borgbackup: dict[str, ServiceBorgbackup] = field(default_factory = dict)
|
||||
packages: dict[str, ServicePackage] = field(default_factory = dict)
|
||||
single_disk: dict[str, ServiceSingleDisk] = field(default_factory = dict, metadata = {"original_name": "single-disk"})
|
||||
single_disk: dict[str, ServiceSingleDisk] = field(default_factory = dict, metadata = {"alias": "single-disk"})
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..facts.upload import upload_secrets
|
||||
from ..machines.machines import Machine
|
||||
from ..nix import nix_command, nix_metadata
|
||||
from ..ssh import HostKeyCheck
|
||||
from ..vars.generate import generate_vars
|
||||
from .inventory import get_all_machines, get_selected_machines
|
||||
from .machine_group import MachineGroup
|
||||
|
||||
@@ -93,6 +94,7 @@ def deploy_machine(machines: MachineGroup) -> None:
|
||||
env["NIX_SSHOPTS"] = ssh_arg
|
||||
|
||||
generate_facts([machine], None, False)
|
||||
generate_vars([machine], None, False)
|
||||
upload_secrets(machine)
|
||||
|
||||
path = upload_sources(".", target)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
setuptools,
|
||||
stdenv,
|
||||
|
||||
pydantic,
|
||||
|
||||
# custom args
|
||||
clan-core-path,
|
||||
nixpkgs,
|
||||
@@ -28,6 +30,7 @@
|
||||
let
|
||||
pythonDependencies = [
|
||||
argcomplete # Enables shell completions
|
||||
pydantic # Dataclass deserialisation / validation / schemas
|
||||
];
|
||||
|
||||
# load nixpkgs runtime dependencies from a json file
|
||||
@@ -181,6 +184,7 @@ python3.pkgs.buildPythonApplication {
|
||||
'';
|
||||
|
||||
# Clean up after the package to avoid leaking python packages into a devshell
|
||||
# TODO: factor seperate cli / API packages
|
||||
postFixup = ''
|
||||
rm $out/nix-support/propagated-build-inputs
|
||||
'';
|
||||
|
||||
@@ -62,7 +62,12 @@
|
||||
name = "clan-cli-docs";
|
||||
src = ./.;
|
||||
|
||||
buildInputs = [ pkgs.python3 ];
|
||||
buildInputs = [
|
||||
|
||||
# TODO: see postFixup clan-cli/default.nix:L188
|
||||
pkgs.python3
|
||||
self'.packages.clan-cli.propagatedBuildInputs
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
${self'.packages.classgen}/bin/classgen ${self'.packages.inventory-schema}/schema.json ./clan_cli/inventory/classes.py
|
||||
@@ -77,7 +82,12 @@
|
||||
name = "clan-ts-api";
|
||||
src = ./.;
|
||||
|
||||
buildInputs = [ pkgs.python3 ];
|
||||
buildInputs = [
|
||||
pkgs.python3
|
||||
|
||||
# TODO: see postFixup clan-cli/default.nix:L188
|
||||
self'.packages.clan-cli.propagatedBuildInputs
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
${self'.packages.classgen}/bin/classgen ${self'.packages.inventory-schema}/schema.json ./clan_cli/inventory/classes.py
|
||||
|
||||
179
pkgs/clan-cli/tests/test_deserializers.py
Normal file
179
pkgs/clan-cli/tests/test_deserializers.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Functions to test
|
||||
from clan_cli.api import dataclass_to_dict, from_dict
|
||||
from clan_cli.errors import ClanError
|
||||
from clan_cli.inventory import (
|
||||
Inventory,
|
||||
Machine,
|
||||
MachineDeploy,
|
||||
Meta,
|
||||
Service,
|
||||
ServiceBorgbackup,
|
||||
ServiceBorgbackupRole,
|
||||
ServiceBorgbackupRoleClient,
|
||||
ServiceBorgbackupRoleServer,
|
||||
ServiceMeta,
|
||||
)
|
||||
|
||||
|
||||
def test_simple() -> None:
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
|
||||
person_dict = {
|
||||
"name": "John",
|
||||
}
|
||||
|
||||
expected_person = Person(
|
||||
name="John",
|
||||
)
|
||||
|
||||
assert from_dict(Person, person_dict) == expected_person
|
||||
|
||||
|
||||
def test_nested() -> None:
|
||||
@dataclass
|
||||
class Age:
|
||||
value: str
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
# deeply nested dataclasses
|
||||
age: Age
|
||||
age_list: list[Age]
|
||||
age_dict: dict[str, Age]
|
||||
# Optional field
|
||||
home: Path | None
|
||||
|
||||
person_dict = {
|
||||
"name": "John",
|
||||
"age": {
|
||||
"value": "99",
|
||||
},
|
||||
"age_list": [{"value": "66"}, {"value": "77"}],
|
||||
"age_dict": {"now": {"value": "55"}, "max": {"value": "100"}},
|
||||
"home": "/home",
|
||||
}
|
||||
|
||||
expected_person = Person(
|
||||
name="John",
|
||||
age=Age("99"),
|
||||
age_list=[Age("66"), Age("77")],
|
||||
age_dict={"now": Age("55"), "max": Age("100")},
|
||||
home=Path("/home"),
|
||||
)
|
||||
|
||||
assert from_dict(Person, person_dict) == expected_person
|
||||
|
||||
|
||||
def test_simple_field_missing() -> None:
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
|
||||
person_dict = {}
|
||||
|
||||
with pytest.raises(ClanError):
|
||||
from_dict(Person, person_dict)
|
||||
|
||||
|
||||
def test_deserialize_extensive_inventory() -> None:
|
||||
# TODO: Make this an abstract test, so it doesn't break the test if the inventory changes
|
||||
data = {
|
||||
"meta": {"name": "superclan", "description": "nice clan"},
|
||||
"services": {
|
||||
"borgbackup": {
|
||||
"instance1": {
|
||||
"meta": {
|
||||
"name": "borg1",
|
||||
},
|
||||
"roles": {
|
||||
"client": {},
|
||||
"server": {},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"machines": {"foo": {"name": "foo", "deploy": {}}},
|
||||
}
|
||||
expected = Inventory(
|
||||
meta=Meta(name="superclan", description="nice clan"),
|
||||
services=Service(
|
||||
borgbackup={
|
||||
"instance1": ServiceBorgbackup(
|
||||
meta=ServiceMeta(name="borg1"),
|
||||
roles=ServiceBorgbackupRole(
|
||||
client=ServiceBorgbackupRoleClient(),
|
||||
server=ServiceBorgbackupRoleServer(),
|
||||
),
|
||||
)
|
||||
}
|
||||
),
|
||||
machines={"foo": Machine(deploy=MachineDeploy(), name="foo")},
|
||||
)
|
||||
assert from_dict(Inventory, data) == expected
|
||||
|
||||
|
||||
def test_alias_field() -> None:
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str = field(metadata={"alias": "--user-name--"})
|
||||
|
||||
data = {"--user-name--": "John"}
|
||||
expected = Person(name="John")
|
||||
|
||||
person = from_dict(Person, data)
|
||||
|
||||
# Deserialize
|
||||
assert person == expected
|
||||
|
||||
# Serialize with alias
|
||||
assert dataclass_to_dict(person) == data
|
||||
|
||||
# Serialize without alias
|
||||
assert dataclass_to_dict(person, use_alias=False) == {"name": "John"}
|
||||
|
||||
|
||||
def test_alias_field_from_orig_name() -> None:
|
||||
"""
|
||||
Field declares an alias. But the data is provided with the field name.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str = field(metadata={"alias": "--user-name--"})
|
||||
|
||||
data = {"user": "John"}
|
||||
|
||||
with pytest.raises(ClanError):
|
||||
from_dict(Person, data)
|
||||
|
||||
|
||||
def test_path_field() -> None:
|
||||
@dataclass
|
||||
class Person:
|
||||
name: Path
|
||||
|
||||
data = {"name": "John"}
|
||||
expected = Person(name=Path("John"))
|
||||
|
||||
assert from_dict(Person, data) == expected
|
||||
|
||||
|
||||
def test_private_public_fields() -> None:
|
||||
@dataclass
|
||||
class Person:
|
||||
name: Path
|
||||
_name: str | None = None
|
||||
|
||||
data = {"name": "John"}
|
||||
expected = Person(name=Path("John"))
|
||||
assert from_dict(Person, data) == expected
|
||||
|
||||
assert dataclass_to_dict(expected) == data
|
||||
@@ -27,7 +27,7 @@ def test_history_add(
|
||||
history_file = user_history_file()
|
||||
assert history_file.exists()
|
||||
history = [HistoryEntry(**entry) for entry in json.loads(open(history_file).read())]
|
||||
assert history[0].flake.flake_url == str(test_flake_with_core.path)
|
||||
assert str(history[0].flake.flake_url["loc"]) == str(test_flake_with_core.path)
|
||||
|
||||
|
||||
@pytest.mark.impure
|
||||
|
||||
106
pkgs/clan-cli/tests/test_serializers.py
Normal file
106
pkgs/clan-cli/tests/test_serializers.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Functions to test
|
||||
from clan_cli.api import (
|
||||
dataclass_to_dict,
|
||||
sanitize_string,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
def test_sanitize_string() -> None:
|
||||
# Simple strings
|
||||
assert sanitize_string("Hello World") == "Hello World"
|
||||
assert sanitize_string("Hello\nWorld") == "Hello\\nWorld"
|
||||
assert sanitize_string("Hello\tWorld") == "Hello\\tWorld"
|
||||
assert sanitize_string("Hello\rWorld") == "Hello\\rWorld"
|
||||
assert sanitize_string("Hello\fWorld") == "Hello\\fWorld"
|
||||
assert sanitize_string("Hello\vWorld") == "Hello\\u000bWorld"
|
||||
assert sanitize_string("Hello\bWorld") == "Hello\\bWorld"
|
||||
assert sanitize_string("Hello\\World") == "Hello\\\\World"
|
||||
assert sanitize_string('Hello"World') == 'Hello\\"World'
|
||||
assert sanitize_string("Hello'World") == "Hello'World"
|
||||
assert sanitize_string("Hello\0World") == "Hello\\u0000World"
|
||||
# Console escape characters
|
||||
|
||||
assert sanitize_string("\033[1mBold\033[0m") == "\\u001b[1mBold\\u001b[0m" # Red
|
||||
assert sanitize_string("\033[31mRed\033[0m") == "\\u001b[31mRed\\u001b[0m" # Blue
|
||||
assert (
|
||||
sanitize_string("\033[42mGreen\033[0m") == "\\u001b[42mGreen\\u001b[0m"
|
||||
) # Green
|
||||
assert sanitize_string("\033[4mUnderline\033[0m") == "\\u001b[4mUnderline\\u001b[0m"
|
||||
assert (
|
||||
sanitize_string("\033[91m\033[1mBold Red\033[0m")
|
||||
== "\\u001b[91m\\u001b[1mBold Red\\u001b[0m"
|
||||
)
|
||||
|
||||
|
||||
def test_dataclass_to_dict() -> None:
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
person = Person(name="John", age=25)
|
||||
expected_dict = {"name": "John", "age": 25}
|
||||
assert dataclass_to_dict(person) == expected_dict
|
||||
|
||||
|
||||
def test_dataclass_to_dict_nested() -> None:
|
||||
@dataclass
|
||||
class Address:
|
||||
city: str = "afghanistan"
|
||||
zip: str = "01234"
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
address: Address = field(default_factory=Address)
|
||||
|
||||
person1 = Person(name="John", age=25)
|
||||
expected_dict1 = {
|
||||
"name": "John",
|
||||
"age": 25,
|
||||
"address": {"city": "afghanistan", "zip": "01234"},
|
||||
}
|
||||
# address must be constructed with default values if not passed
|
||||
assert dataclass_to_dict(person1) == expected_dict1
|
||||
|
||||
person2 = Person(name="John", age=25, address=Address(zip="0", city="Anywhere"))
|
||||
expected_dict2 = {
|
||||
"name": "John",
|
||||
"age": 25,
|
||||
"address": {"zip": "0", "city": "Anywhere"},
|
||||
}
|
||||
assert dataclass_to_dict(person2) == expected_dict2
|
||||
|
||||
|
||||
def test_dataclass_to_dict_defaults() -> None:
|
||||
@dataclass
|
||||
class Foo:
|
||||
home: dict[str, str] = field(default_factory=dict)
|
||||
work: list[str] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str = field(default="jon")
|
||||
age: int = field(default=1)
|
||||
foo: Foo = field(default_factory=Foo)
|
||||
|
||||
default_person = Person()
|
||||
expected_default = {
|
||||
"name": "jon",
|
||||
"age": 1,
|
||||
"foo": {"home": {}, "work": []},
|
||||
}
|
||||
# address must be constructed with default values if not passed
|
||||
assert dataclass_to_dict(default_person) == expected_default
|
||||
|
||||
real_person = Person(name="John", age=25, foo=Foo(home={"a": "b"}, work=["a", "b"]))
|
||||
expected = {
|
||||
"name": "John",
|
||||
"age": 25,
|
||||
"foo": {"home": {"a": "b"}, "work": ["a", "b"]},
|
||||
}
|
||||
assert dataclass_to_dict(real_person) == expected
|
||||
@@ -163,12 +163,12 @@ class ClanStore:
|
||||
del self.clan_store[str(vm.data.flake.flake_url)][vm.data.flake.flake_attr]
|
||||
|
||||
def get_vm(self, uri: ClanURI) -> None | VMObject:
|
||||
flake_id = Machine(uri.machine_name, uri.flake).get_id()
|
||||
vm_store = self.clan_store.get(flake_id)
|
||||
machine = Machine(uri.machine_name, uri.flake)
|
||||
vm_store = self.clan_store.get(str(machine.flake))
|
||||
if vm_store is None:
|
||||
return None
|
||||
machine = vm_store.get(uri.machine_name, None)
|
||||
return machine
|
||||
vm = vm_store.get(str(machine.name), None)
|
||||
return vm
|
||||
|
||||
def get_running_vms(self) -> list[VMObject]:
|
||||
return [
|
||||
|
||||
@@ -39,7 +39,7 @@ let
|
||||
libadwaita
|
||||
webkitgtk_6_0
|
||||
adwaita-icon-theme
|
||||
];
|
||||
] ++ clan-cli.propagatedBuildInputs;
|
||||
|
||||
# Deps including python packages from the local project
|
||||
allPythonDeps = [ (python3.pkgs.toPythonModule clan-cli) ] ++ externalPythonDeps;
|
||||
@@ -84,7 +84,6 @@ python3.pkgs.buildPythonApplication rec {
|
||||
setuptools
|
||||
copyDesktopItems
|
||||
wrapGAppsHook
|
||||
|
||||
gobject-introspection
|
||||
];
|
||||
|
||||
@@ -93,7 +92,7 @@ python3.pkgs.buildPythonApplication rec {
|
||||
# that all necessary dependencies are consistently available both
|
||||
# at build time and runtime,
|
||||
buildInputs = allPythonDeps ++ runtimeDependencies;
|
||||
propagatedBuildInputs = allPythonDeps ++ runtimeDependencies;
|
||||
propagatedBuildInputs = allPythonDeps ++ runtimeDependencies ++ [ ];
|
||||
|
||||
# also re-expose dependencies so we test them in CI
|
||||
passthru = {
|
||||
|
||||
@@ -245,7 +245,7 @@ def generate_dataclass(schema: dict[str, Any], class_name: str = root_class) ->
|
||||
|
||||
field_meta = None
|
||||
if field_name != prop:
|
||||
field_meta = f"""{{"original_name": "{prop}"}}"""
|
||||
field_meta = f"""{{"alias": "{prop}"}}"""
|
||||
|
||||
finalize_field = partial(get_field_def, field_name, field_meta)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import tailwind from "eslint-plugin-tailwindcss";
|
||||
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...pluginQuery.configs["flat/recommended"],
|
||||
...tseslint.configs.strict,
|
||||
...tseslint.configs.stylistic,
|
||||
...tailwind.configs["flat/recommended"],
|
||||
|
||||
312
pkgs/webview-ui/app/package-lock.json
generated
312
pkgs/webview-ui/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,10 @@
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.8",
|
||||
"@modular-forms/solid": "^0.21.0",
|
||||
"@solid-primitives/storage": "^3.7.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.51.12",
|
||||
"@tanstack/solid-query": "^5.51.2",
|
||||
"material-icons": "^1.13.12",
|
||||
"nanoid": "^5.0.7",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, type Component } from "solid-js";
|
||||
import { createEffect, createSignal, type Component } from "solid-js";
|
||||
import { Layout } from "./layout/layout";
|
||||
import { Route, Router } from "./Routes";
|
||||
import { Toaster } from "solid-toast";
|
||||
@@ -7,9 +7,20 @@ import { makePersisted } from "@solid-primitives/storage";
|
||||
|
||||
// Some global state
|
||||
const [route, setRoute] = createSignal<Route>("machines");
|
||||
createEffect(() => {
|
||||
console.log(route());
|
||||
});
|
||||
|
||||
export { route, setRoute };
|
||||
|
||||
const [activeURI, setActiveURI] = createSignal<string | null>(null);
|
||||
const [activeURI, setActiveURI] = makePersisted(
|
||||
createSignal<string | null>(null),
|
||||
{
|
||||
name: "activeURI",
|
||||
storage: localStorage,
|
||||
}
|
||||
);
|
||||
|
||||
export { activeURI, setActiveURI };
|
||||
|
||||
const [clanList, setClanList] = makePersisted(createSignal<string[]>([]), {
|
||||
@@ -17,8 +28,6 @@ const [clanList, setClanList] = makePersisted(createSignal<string[]>([]), {
|
||||
storage: localStorage,
|
||||
});
|
||||
|
||||
clanList() && setActiveURI(clanList()[0]);
|
||||
|
||||
export { clanList, setClanList };
|
||||
|
||||
const App: Component = () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Settings } from "./routes/settings";
|
||||
import { Welcome } from "./routes/welcome";
|
||||
import { Deploy } from "./routes/deploy";
|
||||
import { CreateMachine } from "./routes/machines/create";
|
||||
import { DiskView } from "./routes/disk/view";
|
||||
|
||||
export type Route = keyof typeof routes;
|
||||
|
||||
@@ -63,6 +64,11 @@ export const routes = {
|
||||
label: "deploy",
|
||||
icon: "content_copy",
|
||||
},
|
||||
diskConfig: {
|
||||
child: DiskView,
|
||||
label: "diskConfig",
|
||||
icon: "disk",
|
||||
},
|
||||
};
|
||||
|
||||
interface RouterProps {
|
||||
|
||||
128
pkgs/webview-ui/app/src/floating/index.tsx
Normal file
128
pkgs/webview-ui/app/src/floating/index.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import type {
|
||||
ComputePositionConfig,
|
||||
ComputePositionReturn,
|
||||
ReferenceElement,
|
||||
} from "@floating-ui/dom";
|
||||
import { computePosition } from "@floating-ui/dom";
|
||||
|
||||
export interface UseFloatingOptions<
|
||||
R extends ReferenceElement,
|
||||
F extends HTMLElement,
|
||||
> extends Partial<ComputePositionConfig> {
|
||||
whileElementsMounted?: (
|
||||
reference: R,
|
||||
floating: F,
|
||||
update: () => void
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
) => void | (() => void);
|
||||
}
|
||||
|
||||
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
}
|
||||
|
||||
export interface UseFloatingResult extends UseFloatingState {
|
||||
update(): void;
|
||||
}
|
||||
|
||||
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
reference: () => R | undefined | null,
|
||||
floating: () => F | undefined | null,
|
||||
options?: UseFloatingOptions<R, F>
|
||||
): UseFloatingResult {
|
||||
const placement = () => options?.placement ?? "bottom";
|
||||
const strategy = () => options?.strategy ?? "absolute";
|
||||
|
||||
const [data, setData] = createSignal<UseFloatingState>({
|
||||
x: null,
|
||||
y: null,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
middlewareData: {},
|
||||
});
|
||||
|
||||
const [error, setError] = createSignal<{ value: unknown } | undefined>();
|
||||
|
||||
createEffect(() => {
|
||||
const currentError = error();
|
||||
if (currentError) {
|
||||
throw currentError.value;
|
||||
}
|
||||
});
|
||||
|
||||
const version = createMemo(() => {
|
||||
reference();
|
||||
floating();
|
||||
return {};
|
||||
});
|
||||
|
||||
function update() {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
const capturedVersion = version();
|
||||
computePosition(currentReference, currentFloating, {
|
||||
middleware: options?.middleware,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
}).then(
|
||||
(currentData) => {
|
||||
// Check if it's still valid
|
||||
if (capturedVersion === version()) {
|
||||
setData(currentData);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
options?.middleware;
|
||||
placement();
|
||||
strategy();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
if (options?.whileElementsMounted) {
|
||||
const cleanup = options.whileElementsMounted(
|
||||
currentReference,
|
||||
currentFloating,
|
||||
update
|
||||
);
|
||||
|
||||
if (cleanup) {
|
||||
onCleanup(cleanup);
|
||||
}
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get x() {
|
||||
return data().x;
|
||||
},
|
||||
get y() {
|
||||
return data().y;
|
||||
},
|
||||
get placement() {
|
||||
return data().placement;
|
||||
},
|
||||
get strategy() {
|
||||
return data().strategy;
|
||||
},
|
||||
get middlewareData() {
|
||||
return data().middlewareData;
|
||||
},
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { activeURI, setRoute } from "../App";
|
||||
import { callApi } from "../api";
|
||||
import { Show } from "solid-js";
|
||||
import { Accessor, createEffect, Show } from "solid-js";
|
||||
|
||||
export const Header = () => {
|
||||
const { isLoading, data } = createQuery(() => ({
|
||||
queryKey: [`${activeURI()}:meta`],
|
||||
interface HeaderProps {
|
||||
clan_dir: Accessor<string | null>;
|
||||
}
|
||||
export const Header = (props: HeaderProps) => {
|
||||
const { clan_dir } = props;
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: [clan_dir(), "meta"],
|
||||
queryFn: async () => {
|
||||
const currUri = activeURI();
|
||||
if (currUri) {
|
||||
const result = await callApi("show_clan_meta", { uri: currUri });
|
||||
const curr = clan_dir();
|
||||
if (curr) {
|
||||
const result = await callApi("show_clan_meta", { uri: curr });
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
}
|
||||
@@ -29,16 +34,25 @@ export const Header = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tooltip tooltip-right" data-tip={data?.name || activeURI()}>
|
||||
<div class="avatar placeholder online mx-4">
|
||||
<div class="w-10 rounded-full bg-slate-700 text-neutral-content">
|
||||
<span class="text-xl">C</span>
|
||||
<Show when={data?.name}>
|
||||
{(name) => <span class="text-xl">{name()}</span>}
|
||||
</Show>
|
||||
<Show when={!query.isFetching && query.data}>
|
||||
{(meta) => (
|
||||
<div class="tooltip tooltip-right" data-tip={activeURI()}>
|
||||
<div class="avatar placeholder online mx-4">
|
||||
<div class="w-10 rounded-full bg-slate-700 text-3xl text-neutral-content">
|
||||
{meta().name.slice(0, 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<span class="flex flex-col">
|
||||
<Show when={!query.isFetching && query.data}>
|
||||
{(meta) => [
|
||||
<span class="text-primary">{meta().name}</span>,
|
||||
<span class="text-neutral">{meta()?.description}</span>,
|
||||
]}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<span class="tooltip tooltip-bottom" data-tip="Settings">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, JSXElement, Show } from "solid-js";
|
||||
import { Header } from "./header";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { clanList, route, setRoute } from "../App";
|
||||
import { activeURI, clanList, route, setRoute } from "../App";
|
||||
|
||||
interface LayoutProps {
|
||||
children: JSXElement;
|
||||
@@ -18,7 +18,7 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
/>
|
||||
<div class="drawer-content">
|
||||
<Show when={route() !== "welcome"}>
|
||||
<Header />
|
||||
<Header clan_dir={activeURI} />
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
175
pkgs/webview-ui/app/src/routes/clan/editClan.tsx
Normal file
175
pkgs/webview-ui/app/src/routes/clan/editClan.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { OperationResponse, callApi, pyApi } from "@/src/api";
|
||||
import { Accessor, Show, Switch, Match } from "solid-js";
|
||||
import {
|
||||
SubmitHandler,
|
||||
createForm,
|
||||
required,
|
||||
reset,
|
||||
} from "@modular-forms/solid";
|
||||
import toast from "solid-toast";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
|
||||
type CreateForm = Meta;
|
||||
|
||||
interface EditClanFormProps {
|
||||
directory: Accessor<string>;
|
||||
done: () => void;
|
||||
}
|
||||
export const EditClanForm = (props: EditClanFormProps) => {
|
||||
const { directory } = props;
|
||||
const details = createQuery(() => ({
|
||||
queryKey: [directory(), "meta"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("show_clan_meta", { uri: directory() });
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={details?.data}>
|
||||
{(data) => (
|
||||
<FinalEditClanForm
|
||||
initial={data()}
|
||||
directory={directory()}
|
||||
done={props.done}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
interface FinalEditClanFormProps {
|
||||
initial: CreateForm;
|
||||
directory: string;
|
||||
done: () => void;
|
||||
}
|
||||
export const FinalEditClanForm = (props: FinalEditClanFormProps) => {
|
||||
const [formStore, { Form, Field }] = createForm<CreateForm>({
|
||||
initialValues: props.initial,
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<CreateForm> = async (values, event) => {
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
await callApi("update_clan_meta", {
|
||||
options: {
|
||||
directory: props.directory,
|
||||
meta: values,
|
||||
},
|
||||
});
|
||||
})(),
|
||||
{
|
||||
loading: "Updating clan...",
|
||||
success: "Clan Successfully updated",
|
||||
error: "Failed to update clan",
|
||||
}
|
||||
);
|
||||
props.done();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="card card-normal">
|
||||
<Form onSubmit={handleSubmit} shouldActive>
|
||||
<Field name="icon">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<figure>
|
||||
<Show
|
||||
when={field.value}
|
||||
fallback={
|
||||
<span class="material-icons aspect-square size-60 rounded-lg text-[18rem]">
|
||||
group
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(icon) => (
|
||||
<img
|
||||
class="aspect-square size-60 rounded-lg"
|
||||
src={icon()}
|
||||
alt="Clan Logo"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</figure>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<div class="card-body">
|
||||
<Field
|
||||
name="name"
|
||||
validate={[required("Please enter a unique name for the clan.")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text block after:ml-0.5 after:text-primary after:content-['*']">
|
||||
Name
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...props}
|
||||
disabled={formStore.submitting}
|
||||
required
|
||||
placeholder="Clan Name"
|
||||
class="input input-bordered"
|
||||
classList={{ "input-error": !!field.error }}
|
||||
value={field.value}
|
||||
/>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt">{field.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{(field, props) => (
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...props}
|
||||
disabled={formStore.submitting}
|
||||
required
|
||||
type="text"
|
||||
placeholder="Some words about your clan"
|
||||
class="input input-bordered"
|
||||
classList={{ "input-error": !!field.error }}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt">{field.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
{
|
||||
<div class="card-actions justify-end">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={formStore.submitting}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Meta = Extract<
|
||||
OperationResponse<"show_clan_meta">,
|
||||
{ status: "success" }
|
||||
>["data"];
|
||||
33
pkgs/webview-ui/app/src/routes/disk/view.tsx
Normal file
33
pkgs/webview-ui/app/src/routes/disk/view.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { callApi } from "@/src/api";
|
||||
import { activeURI } from "@/src/App";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { createEffect } from "solid-js";
|
||||
import toast from "solid-toast";
|
||||
|
||||
export function DiskView() {
|
||||
const query = createQuery(() => ({
|
||||
queryKey: ["disk", activeURI()],
|
||||
queryFn: async () => {
|
||||
const currUri = activeURI();
|
||||
if (currUri) {
|
||||
// Example of calling an API
|
||||
const result = await callApi("get_inventory", { base_path: currUri });
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
}
|
||||
},
|
||||
}));
|
||||
createEffect(() => {
|
||||
// Example debugging the data
|
||||
console.log(query);
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<h1>Configure Disk</h1>
|
||||
<p>
|
||||
Select machine then configure the disk. Required before installing for
|
||||
the first time.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,19 @@ import {
|
||||
setRoute,
|
||||
clanList,
|
||||
} from "@/src/App";
|
||||
import { For, Show } from "solid-js";
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
Setter,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { useFloating } from "@/src/floating";
|
||||
import { autoUpdate, flip, hide, offset, shift } from "@floating-ui/dom";
|
||||
import { EditClanForm } from "../clan/editClan";
|
||||
|
||||
export const registerClan = async () => {
|
||||
try {
|
||||
@@ -41,9 +52,10 @@ export const registerClan = async () => {
|
||||
|
||||
interface ClanDetailsProps {
|
||||
clan_dir: string;
|
||||
setEditURI: Setter<string | null>;
|
||||
}
|
||||
const ClanDetails = (props: ClanDetailsProps) => {
|
||||
const { clan_dir } = props;
|
||||
const { clan_dir, setEditURI } = props;
|
||||
|
||||
const details = createQuery(() => ({
|
||||
queryKey: [clan_dir, "meta"],
|
||||
@@ -54,10 +66,41 @@ const ClanDetails = (props: ClanDetailsProps) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const [reference, setReference] = createSignal<HTMLElement>();
|
||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||
|
||||
// `position` is a reactive object.
|
||||
const position = useFloating(reference, floating, {
|
||||
placement: "top",
|
||||
|
||||
// pass options. Ensure the cleanup function is returned.
|
||||
whileElementsMounted: (reference, floating, update) =>
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: true,
|
||||
}),
|
||||
middleware: [
|
||||
offset(5),
|
||||
shift(),
|
||||
flip(),
|
||||
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<div class="join">
|
||||
<button
|
||||
class=" join-item btn-sm"
|
||||
onClick={() => {
|
||||
setEditURI(clan_dir);
|
||||
}}
|
||||
>
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
<button
|
||||
class=" join-item btn-sm"
|
||||
classList={{
|
||||
@@ -72,75 +115,96 @@ const ClanDetails = (props: ClanDetailsProps) => {
|
||||
{activeURI() === clan_dir ? "active" : "select"}
|
||||
</button>
|
||||
<button
|
||||
popovertarget={`clan-delete-popover-${clan_dir}`}
|
||||
popovertargetaction="toggle"
|
||||
ref={setReference}
|
||||
class="btn btn-ghost btn-outline join-item btn-sm"
|
||||
onClick={() => {
|
||||
setClanList((s) =>
|
||||
s.filter((v, idx) => {
|
||||
if (v == clan_dir) {
|
||||
setActiveURI(
|
||||
clanList()[idx - 1] || clanList()[idx + 1] || null
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<div
|
||||
popover="auto"
|
||||
id={`clan-delete-popover-${clan_dir}`}
|
||||
ref={setFloating}
|
||||
style={{
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="bg-transparent"
|
||||
>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
onClick={() => {
|
||||
setClanList((s) =>
|
||||
s.filter((v, idx) => {
|
||||
if (v == clan_dir) {
|
||||
setActiveURI(
|
||||
clanList()[idx - 1] || clanList()[idx + 1] || null
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove from App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-title">Clan URI</div>
|
||||
<div class="stat-title">{clan_dir}</div>
|
||||
|
||||
<Show when={details.isSuccess}>
|
||||
<div
|
||||
class="stat-value"
|
||||
// classList={{
|
||||
// "text-primary": activeURI() === clan_dir,
|
||||
// }}
|
||||
>
|
||||
{details.data?.name}
|
||||
</div>
|
||||
<div class="stat-value">{details.data?.name}</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={details.isSuccess && details.data?.description}
|
||||
fallback={<div class="stat-desc text-lg">{clan_dir}</div>}
|
||||
>
|
||||
<div
|
||||
class="stat-desc text-lg"
|
||||
// classList={{
|
||||
// "text-primary": activeURI() === clan_dir,
|
||||
// }}
|
||||
>
|
||||
{details.data?.description}
|
||||
</div>
|
||||
<Show when={details.isSuccess && details.data?.description}>
|
||||
<div class="stat-desc text-lg">{details.data?.description}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Settings = () => {
|
||||
const [editURI, setEditURI] = createSignal<string | null>(null);
|
||||
|
||||
return (
|
||||
<div class="card card-normal">
|
||||
<div class="card-body">
|
||||
<div class="label">
|
||||
<div class="label-text">Registered Clans</div>
|
||||
<button
|
||||
class="btn btn-square btn-primary"
|
||||
onClick={() => {
|
||||
registerClan();
|
||||
}}
|
||||
>
|
||||
<span class="material-icons">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="stats stats-vertical shadow">
|
||||
<For each={clanList()}>
|
||||
{(value) => <ClanDetails clan_dir={value} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={editURI()}>
|
||||
{(uri) => (
|
||||
<EditClanForm
|
||||
directory={uri}
|
||||
done={() => {
|
||||
setEditURI(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={!editURI()}>
|
||||
<div class="card-body">
|
||||
<div class="label">
|
||||
<div class="label-text">Registered Clans</div>
|
||||
<button
|
||||
class="btn btn-square btn-primary"
|
||||
onClick={() => {
|
||||
registerClan();
|
||||
}}
|
||||
>
|
||||
<span class="material-icons">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="stats stats-vertical shadow">
|
||||
<For each={clanList()}>
|
||||
{(value) => (
|
||||
<ClanDetails clan_dir={value} setEditURI={setEditURI} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
src = ./app;
|
||||
hash = "sha256-/PFSBAIodZjInElYoNsDQUV4isxmcvL3YM1hzAmdDWA=";
|
||||
hash = "sha256-n9IXcfCpydykoYD+P/YNtNIwrvgJTZND0kg7oXBfmJ0=";
|
||||
};
|
||||
# The prepack script runs the build script, which we'd rather do in the build phase.
|
||||
npmPackFlags = [ "--ignore-scripts" ];
|
||||
|
||||
Reference in New Issue
Block a user