Merge remote-tracking branch 'origin/main' into rework-installation

This commit is contained in:
Jörg Thalheim
2024-07-30 11:52:36 +02:00
48 changed files with 1385 additions and 562 deletions

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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
'';
}

View File

@@ -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,

View File

@@ -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")

View 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))

View File

@@ -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("_")
}

View File

@@ -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"],

View File

@@ -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(),

View File

@@ -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()
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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
'';

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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 [

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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"],

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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 = () => {

View File

@@ -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 {

View 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,
};
}

View File

@@ -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">

View File

@@ -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>

View 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"];

View 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>
);
}

View File

@@ -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>
);
};

View File

@@ -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" ];