diff --git a/pkgs/clan-app/clan_app/views/webview.py b/pkgs/clan-app/clan_app/views/webview.py index 83ebbbe2..8f0a883b 100644 --- a/pkgs/clan-app/clan_app/views/webview.py +++ b/pkgs/clan-app/clan_app/views/webview.py @@ -25,6 +25,10 @@ site_index: Path = ( log = logging.getLogger(__name__) +def sanitize_string(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"') + + def dataclass_to_dict(obj: Any) -> Any: """ Utility function to convert dataclasses to dictionaries @@ -33,13 +37,18 @@ def dataclass_to_dict(obj: Any) -> Any: It does NOT convert member functions. """ if dataclasses.is_dataclass(obj): - return {k: dataclass_to_dict(v) for k, v in dataclasses.asdict(obj).items()} + 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 {k: dataclass_to_dict(v) for k, v in obj.items()} + 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 diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 2fea2d57..1b8566f0 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -5,11 +5,11 @@ from pathlib import Path from types import ModuleType # These imports are unused, but necessary for @API.register to run once. -from clan_cli.api import directory +from clan_cli.api import directory, mdns_discovery from clan_cli.arg_actions import AppendOptionAction from clan_cli.clan import show -__all__ = ["directory"] +__all__ = ["directory", "mdns_discovery"] from . import ( backups, diff --git a/pkgs/clan-cli/clan_cli/api/mdns_discovery.py b/pkgs/clan-cli/clan_cli/api/mdns_discovery.py new file mode 100644 index 00000000..64fc9cf8 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/api/mdns_discovery.py @@ -0,0 +1,116 @@ +import argparse +import re +from dataclasses import dataclass + +from clan_cli.cmd import run_no_stdout +from clan_cli.nix import nix_shell + +from . import API + + +@dataclass +class Host: + # Part of the discovery + interface: str + protocol: str + name: str + type_: str + domain: str + # Optional, only if more data is available + host: str | None + ip: str | None + port: str | None + txt: str | None + + +@dataclass +class DNSInfo: + """ " + mDNS/DNS-SD services discovered on the network + """ + + services: dict[str, Host] + + +def decode_escapes(s: str) -> str: + return re.sub(r"\\(\d{3})", lambda x: chr(int(x.group(1))), s) + + +def parse_avahi_output(output: str) -> DNSInfo: + dns_info = DNSInfo(services={}) + for line in output.splitlines(): + parts = line.split(";") + # New service discovered + # print(parts) + if parts[0] == "+" and len(parts) >= 6: + interface, protocol, name, type_, domain = parts[1:6] + + name = decode_escapes(name) + + dns_info.services[name] = Host( + interface=interface, + protocol=protocol, + name=name, + type_=decode_escapes(type_), + domain=domain, + host=None, + ip=None, + port=None, + txt=None, + ) + + # Resolved more data for already discovered services + elif parts[0] == "=" and len(parts) >= 9: + interface, protocol, name, type_, domain, host, ip, port = parts[1:9] + + name = decode_escapes(name) + + if name in dns_info.services: + dns_info.services[name].host = decode_escapes(host) + dns_info.services[name].ip = ip + dns_info.services[name].port = port + if len(parts) > 9: + dns_info.services[name].txt = decode_escapes(parts[9]) + else: + dns_info.services[name] = Host( + interface=parts[1], + protocol=parts[2], + name=name, + type_=decode_escapes(parts[4]), + domain=parts[5], + host=decode_escapes(parts[6]), + ip=parts[7], + port=parts[8], + txt=decode_escapes(parts[9]) if len(parts) > 9 else None, + ) + + return dns_info + + +@API.register +def show_mdns() -> DNSInfo: + cmd = nix_shell( + ["nixpkgs#avahi"], + [ + "avahi-browse", + "--all", + "--resolve", + "--parsable", + "-l", # Ignore local services + "--terminate", + ], + ) + proc = run_no_stdout(cmd) + data = parse_avahi_output(proc.stdout) + + return data + + +def mdns_command(args: argparse.Namespace) -> None: + dns_info = show_mdns() + for name, info in dns_info.services.items(): + print(f"Hostname: {name} - ip: {info.ip}") + + +def register_mdns(parser: argparse.ArgumentParser) -> None: + parser.set_defaults(func=mdns_command) diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index 3cd73c63..38bfcf32 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -20,6 +20,72 @@ class HardwareInfo: system: str | None +@API.register +def show_machine_hardware_info( + clan_dir: str | Path, machine_name: str +) -> HardwareInfo | None: + """ + Show hardware information for a machine returns None if none exist. + """ + + hw_file = Path(f"{clan_dir}/machines/{machine_name}/hardware-configuration.nix") + + is_template = hw_file.exists() and "throw" in hw_file.read_text() + if not hw_file.exists() or is_template: + return None + + system = show_machine_hardware_platform(clan_dir, machine_name) + return HardwareInfo(system) + + +@API.register +def show_machine_deployment_target( + clan_dir: str | Path, machine_name: str +) -> str | None: + """ + Show hardware information for a machine returns None if none exist. + """ + config = nix_config() + system = config["system"] + cmd = nix_eval( + [ + f"{clan_dir}#clanInternals.machines.{system}.{machine_name}", + "--apply", + "machine: { inherit (machine.config.clan.networking) targetHost; }", + "--json", + ] + ) + proc = run_no_stdout(cmd) + res = proc.stdout.strip() + + target_host = json.loads(res) + return target_host.get("targetHost", None) + + +@API.register +def show_machine_hardware_platform( + clan_dir: str | Path, machine_name: str +) -> str | None: + """ + Show hardware information for a machine returns None if none exist. + """ + config = nix_config() + system = config["system"] + cmd = nix_eval( + [ + f"{clan_dir}#clanInternals.machines.{system}.{machine_name}", + "--apply", + "machine: { inherit (machine.config.nixpkgs.hostPlatform) system; }", + "--json", + ] + ) + proc = run_no_stdout(cmd) + res = proc.stdout.strip() + + host_platform = json.loads(res) + return host_platform.get("system", None) + + @API.register def generate_machine_hardware_info( clan_dir: str | Path, @@ -63,9 +129,7 @@ def generate_machine_hardware_info( hw_file.parent.mkdir(parents=True, exist_ok=True) # Check if the hardware-configuration.nix file is a template - is_template = False - if hw_file.exists(): - is_template = "throw" in hw_file.read_text() + is_template = hw_file.exists() and "throw" in hw_file.read_text() if hw_file.exists() and not force and not is_template: raise ClanError( @@ -78,25 +142,8 @@ def generate_machine_hardware_info( f.write(out.stdout) print(f"Successfully generated: {hw_file}") - # TODO: This could be its own API function? - config = nix_config() - system = config["system"] - cmd = nix_eval( - [ - f"{clan_dir}#clanInternals.machines.{system}.{machine_name}", - "--apply", - "machine: { inherit (machine.config.nixpkgs.hostPlatform) system; }", - "--json", - ] - ) - proc = run_no_stdout(cmd) - res = proc.stdout.strip() - - host_platform = json.loads(res) - - return HardwareInfo( - system=host_platform.get("system", None), - ) + system = show_machine_hardware_platform(clan_dir, machine_name) + return HardwareInfo(system) def hw_generate_command(args: argparse.Namespace) -> None: diff --git a/pkgs/webview-ui/app/src/App.tsx b/pkgs/webview-ui/app/src/App.tsx index c70f93a7..9ec71250 100644 --- a/pkgs/webview-ui/app/src/App.tsx +++ b/pkgs/webview-ui/app/src/App.tsx @@ -8,7 +8,7 @@ import { Toaster } from "solid-toast"; const [route, setRoute] = createSignal("machines"); const [currClanURI, setCurrClanURI] = createSignal( - "/home/johannes/1_clans/myclan" + "/home/johannes/git/testing/xd" ); export { currClanURI, setCurrClanURI }; diff --git a/pkgs/webview-ui/app/src/api.ts b/pkgs/webview-ui/app/src/api.ts index d0e8ab10..fd4d5dec 100644 --- a/pkgs/webview-ui/app/src/api.ts +++ b/pkgs/webview-ui/app/src/api.ts @@ -100,6 +100,7 @@ const deserialize = try { fn(JSON.parse(str) as T); } catch (e) { + console.error(str); alert(`Error parsing JSON: ${e}`); } }; diff --git a/pkgs/webview-ui/app/src/components/MachineListItem.tsx b/pkgs/webview-ui/app/src/components/MachineListItem.tsx index 964585d3..78bc4bf0 100644 --- a/pkgs/webview-ui/app/src/components/MachineListItem.tsx +++ b/pkgs/webview-ui/app/src/components/MachineListItem.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal } from "solid-js"; +import { For, Match, Show, Switch, createSignal } from "solid-js"; import { ErrorData, SuccessData, pyApi } from "../api"; import { currClanURI } from "../App"; @@ -7,10 +7,19 @@ interface MachineListItemProps { } type MachineDetails = Record["data"]>; +type HWInfo = Record["data"]>; +type DeploymentInfo = Record< + string, + SuccessData<"show_machine_deployment_target">["data"] +>; type MachineErrors = Record["errors"]>; const [details, setDetails] = createSignal({}); +const [hwInfo, setHwInfo] = createSignal({}); + +const [deploymentInfo, setDeploymentInfo] = createSignal({}); + const [errors, setErrors] = createSignal({}); pyApi.show_machine.receive((r) => { @@ -26,6 +35,34 @@ pyApi.show_machine.receive((r) => { } }); +pyApi.show_machine_hardware_info.receive((r) => { + const { op_key } = r; + if (r.status === "error") { + console.error(r.errors); + if (op_key) { + setHwInfo((d) => ({ ...d, [op_key]: { system: null } })); + } + return; + } + if (op_key) { + setHwInfo((d) => ({ ...d, [op_key]: r.data })); + } +}); + +pyApi.show_machine_deployment_target.receive((r) => { + const { op_key } = r; + if (r.status === "error") { + console.error(r.errors); + if (op_key) { + setDeploymentInfo((d) => ({ ...d, [op_key]: null })); + } + return; + } + if (op_key) { + setDeploymentInfo((d) => ({ ...d, [op_key]: r.data })); + } +}); + export const MachineListItem = (props: MachineListItemProps) => { const { name } = props; @@ -35,6 +72,18 @@ export const MachineListItem = (props: MachineListItemProps) => { flake_url: currClanURI(), }); + pyApi.show_machine_hardware_info.dispatch({ + op_key: name, + clan_dir: currClanURI(), + machine_name: name, + }); + + pyApi.show_machine_deployment_target.dispatch({ + op_key: name, + clan_dir: currClanURI(), + machine_name: name, + }); + return (
  • @@ -46,27 +95,60 @@ export const MachineListItem = (props: MachineListItemProps) => {

    {name}

    - - {(errors) => ( - - {(error) => ( -

    - {error.message}: {error.description} -

    - )} -
    - )} -
    - - {(details) => ( -

    +

    }> + + No description + + + } + > + {(d) => d()?.machine_description} + +
    +
    +
    + + {hwInfo()[name]?.system ? "check" : "pending"} + + +
    }> + + {(system) => "System: " + system()} + + + {"No hardware info"} + + +
    + +
    + + {deploymentInfo()[name] ? "check" : "pending"} + +
    }> + + No deployment target detected + + + } > - {details().machine_description || "No description"} -

    + {(i) => "Deploys to: " + i()} + +
    + + {/* Show only the first error at the bottom */} + + {(error) => ( +
    + Error: {error().message}: {error().description} +
    )}
    diff --git a/pkgs/webview-ui/app/src/routes/machines/view.tsx b/pkgs/webview-ui/app/src/routes/machines/view.tsx index 342dbdfc..2e6e5b62 100644 --- a/pkgs/webview-ui/app/src/routes/machines/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/view.tsx @@ -1,6 +1,7 @@ import { For, Match, + Show, Switch, createEffect, createSignal, @@ -17,6 +18,11 @@ type FilesModel = Extract< { status: "success" } >["data"]["files"]; +type ServiceModel = Extract< + OperationResponse<"show_mdns">, + { status: "success" } +>["data"]["services"]; + export const MachineListView: Component = () => { const [{ machines, loading }, { getMachines }] = useMachineContext(); @@ -27,6 +33,13 @@ export const MachineListView: Component = () => { setFiles(r.data.files); }); + const [services, setServices] = createSignal(); + pyApi.show_mdns.receive((r) => { + const { status } = r; + if (status === "error") return console.error(r.errors); + setServices(r.data.services); + }); + createEffect(() => { console.log(files()); }); @@ -72,11 +85,66 @@ export const MachineListView: Component = () => { folder_open +
    + +
    + + {(services) => ( + + {(service) => ( +
    +

    {service.name}

    +

    + Interface: + {service.interface} +

    +

    + Protocol: + {service.protocol} +

    +

    + Name + {service.name} +

    +

    + Type: + {service.type_} +

    +

    + Domain: + {service.domain} +

    +

    + Host: + {service.host} +

    +

    + IP: + {service.ip} +

    +

    + Port: + {service.port} +

    +

    + TXT: + {service.txt} +

    +
    + )} +
    + )} +
    {/* Loading skeleton */}