diff --git a/pkgs/clan-app/clan_app/api/file.py b/pkgs/clan-app/clan_app/api/file.py index a86f23874..b6e2b22d8 100644 --- a/pkgs/clan-app/clan_app/api/file.py +++ b/pkgs/clan-app/clan_app/api/file.py @@ -4,6 +4,8 @@ import gi gi.require_version("Gtk", "4.0") import logging +from pathlib import Path +from typing import Any from clan_cli.api import ErrorDataClass, SuccessDataClass from clan_cli.api.directory import FileRequest @@ -17,7 +19,7 @@ log = logging.getLogger(__name__) # This implements the abstract function open_file with one argument, file_request, # which is a FileRequest object and returns a string or None. class open_file( - ImplFunc[[FileRequest, str], SuccessDataClass[str | None] | ErrorDataClass] + ImplFunc[[FileRequest, str], SuccessDataClass[list[str] | None] | ErrorDataClass] ): def __init__(self) -> None: super().__init__() @@ -27,7 +29,7 @@ class open_file( try: gfile = file_dialog.open_finish(task) if gfile: - selected_path = gfile.get_path() + selected_path = [gfile.get_path()] self.returns( SuccessDataClass( op_key=op_key, data=selected_path, status="success" @@ -36,11 +38,26 @@ class open_file( except Exception as e: print(f"Error getting selected file or directory: {e}") + def on_file_select_multiple( + file_dialog: Gtk.FileDialog, task: Gio.Task + ) -> None: + try: + gfiles: Any = file_dialog.open_multiple_finish(task) + if gfiles: + selected_paths = [gfile.get_path() for gfile in gfiles] + self.returns( + SuccessDataClass( + op_key=op_key, data=selected_paths, status="success" + ) + ) + except Exception as e: + print(f"Error getting selected files: {e}") + def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None: try: gfile = file_dialog.select_folder_finish(task) if gfile: - selected_path = gfile.get_path() + selected_path = [gfile.get_path()] self.returns( SuccessDataClass( op_key=op_key, data=selected_path, status="success" @@ -53,7 +70,7 @@ class open_file( try: gfile = file_dialog.save_finish(task) if gfile: - selected_path = gfile.get_path() + selected_path = [gfile.get_path()] self.returns( SuccessDataClass( op_key=op_key, data=selected_path, status="success" @@ -90,9 +107,21 @@ class open_file( filters.append(file_filters) dialog.set_filters(filters) + if file_request.initial_file: + p = Path(file_request.initial_file).expanduser() + f = Gio.File.new_for_path(str(p)) + dialog.set_initial_file(f) + + if file_request.initial_folder: + p = Path(file_request.initial_folder).expanduser() + f = Gio.File.new_for_path(str(p)) + dialog.set_initial_folder(f) + # if select_folder if file_request.mode == "select_folder": dialog.select_folder(callback=on_folder_select) + if file_request.mode == "open_multiple_files": + dialog.open_multiple(callback=on_file_select_multiple) elif file_request.mode == "open_file": dialog.open(callback=on_file_select) elif file_request.mode == "save": diff --git a/pkgs/clan-app/clan_app/windows/main_window.py b/pkgs/clan-app/clan_app/windows/main_window.py index d540dbc1e..cd1c42971 100644 --- a/pkgs/clan-app/clan_app/windows/main_window.py +++ b/pkgs/clan-app/clan_app/windows/main_window.py @@ -1,4 +1,5 @@ import logging +import os import gi from clan_cli.api import API @@ -47,3 +48,4 @@ class MainWindow(Adw.ApplicationWindow): def on_destroy(self, source: "Adw.ApplicationWindow") -> None: log.debug("Destroying Adw.ApplicationWindow") + os._exit(0) diff --git a/pkgs/clan-cli/clan_cli/api/cli.py b/pkgs/clan-cli/clan_cli/api/cli.py new file mode 100755 index 000000000..763d5ec24 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/api/cli.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +import argparse +import json + +from clan_cli.api import API + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Debug the API.") + args = parser.parse_args() + + schema = API.to_json_schema() + print(json.dumps(schema, indent=4)) diff --git a/pkgs/clan-cli/clan_cli/api/directory.py b/pkgs/clan-cli/clan_cli/api/directory.py index c7f95b87f..d47225b3b 100644 --- a/pkgs/clan-cli/clan_cli/api/directory.py +++ b/pkgs/clan-cli/clan_cli/api/directory.py @@ -12,24 +12,26 @@ from . import API @dataclass class FileFilter: - title: str | None - mime_types: list[str] | None - patterns: list[str] | None - suffixes: list[str] | None + title: str | None = field(default=None) + mime_types: list[str] | None = field(default=None) + patterns: list[str] | None = field(default=None) + suffixes: list[str] | None = field(default=None) @dataclass class FileRequest: # Mode of the os dialog window - mode: Literal["open_file", "select_folder", "save"] + mode: Literal["open_file", "select_folder", "save", "open_multiple_files"] # Title of the os dialog window - title: str | None = None + title: str | None = field(default=None) # Pre-applied filters for the file dialog - filters: FileFilter | None = None + filters: FileFilter | None = field(default=None) + initial_file: str | None = field(default=None) + initial_folder: str | None = field(default=None) @API.register_abstract -def open_file(file_request: FileRequest) -> str | None: +def open_file(file_request: FileRequest) -> list[str] | None: """ Abstract api method to open a file dialog window. It must return the name of the selected file or None if no file was selected. @@ -88,6 +90,7 @@ def get_directory(current_path: str) -> Directory: @dataclass class BlkInfo: name: str + path: str rm: str size: str ro: bool @@ -103,6 +106,7 @@ class Blockdevices: def blk_from_dict(data: dict) -> BlkInfo: return BlkInfo( name=data["name"], + path=data["path"], rm=data["rm"], size=data["size"], ro=data["ro"], @@ -117,7 +121,10 @@ def show_block_devices() -> Blockdevices: Abstract api method to show block devices. It must return a list of block devices. """ - cmd = nix_shell(["nixpkgs#util-linux"], ["lsblk", "--json"]) + cmd = nix_shell( + ["nixpkgs#util-linux"], + ["lsblk", "--json", "--output", "PATH,NAME,RM,SIZE,RO,MOUNTPOINTS,TYPE"], + ) proc = run_no_stdout(cmd) res = proc.stdout.strip() diff --git a/pkgs/clan-cli/clan_cli/api/serde.py b/pkgs/clan-cli/clan_cli/api/serde.py index 298cbe9de..ce7089535 100644 --- a/pkgs/clan-cli/clan_cli/api/serde.py +++ b/pkgs/clan-cli/clan_cli/api/serde.py @@ -237,7 +237,7 @@ def from_dict(t: type[T], data: dict[str, Any], path: list[str] = []) -> T: if field_name not in field_values: formatted_path = " ".join(path) raise ClanError( - f"Required field missing: '{field_name}' in {t} {formatted_path}, got Value: {data}" + f"Default value missing for: '{field_name}' in {t} {formatted_path}, got Value: {data}" ) return t(**field_values) # type: ignore diff --git a/pkgs/clan-cli/clan_cli/flash.py b/pkgs/clan-cli/clan_cli/flash.py index f846068a5..3c9df9616 100644 --- a/pkgs/clan-cli/clan_cli/flash.py +++ b/pkgs/clan-cli/clan_cli/flash.py @@ -6,7 +6,7 @@ import os import shutil import textwrap from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -19,23 +19,105 @@ from .completions import add_dynamic_completer, complete_machines from .errors import ClanError from .facts.secret_modules import SecretStoreBase from .machines.machines import Machine -from .nix import nix_shell +from .nix import nix_build, nix_shell log = logging.getLogger(__name__) +@dataclass +class SystemConfig: + language: str | None = field(default=None) + keymap: str | None = field(default=None) + ssh_keys_path: list[str] | None = field(default=None) + + +@API.register +def list_possible_keymaps() -> list[str]: + cmd = nix_build(["nixpkgs#kbd"]) + result = run(cmd, log=Log.STDERR, error_msg="Failed to find kbdinfo") + keymaps_dir = Path(result.stdout.strip()) / "share" / "keymaps" + + if not keymaps_dir.exists(): + raise FileNotFoundError(f"Keymaps directory '{keymaps_dir}' does not exist.") + + keymap_files = [] + + for root, _, files in os.walk(keymaps_dir): + for file in files: + if file.endswith(".map.gz"): + # Remove '.map.gz' ending + name_without_ext = file[:-7] + keymap_files.append(name_without_ext) + + return keymap_files + + +@API.register +def list_possible_languages() -> list[str]: + cmd = nix_build(["nixpkgs#glibcLocales"]) + result = run(cmd, log=Log.STDERR, error_msg="Failed to find glibc locales") + locale_file = Path(result.stdout.strip()) / "share" / "i18n" / "SUPPORTED" + + if not locale_file.exists(): + raise FileNotFoundError(f"Locale file '{locale_file}' does not exist.") + + with locale_file.open() as f: + lines = f.readlines() + + languages = [] + for line in lines: + if line.startswith("#"): + continue + if "SUPPORTED-LOCALES" in line: + continue + # Split by '/' and take the first part + language = line.split("/")[0].strip() + languages.append(language) + + return languages + + @API.register def flash_machine( machine: Machine, *, mode: str, disks: dict[str, str], - system_config: dict[str, Any], + system_config: SystemConfig, dry_run: bool, write_efi_boot_entries: bool, debug: bool, extra_args: list[str] = [], ) -> None: + system_config_nix: dict[str, Any] = {} + + if system_config.language: + if system_config.language not in list_possible_languages(): + raise ClanError( + f"Language '{system_config.language}' is not a valid language. " + f"Run 'clan flash --list-languages' to see a list of possible languages." + ) + system_config_nix["i18n"] = {"defaultLocale": system_config.language} + + if system_config.keymap: + if system_config.keymap not in list_possible_keymaps(): + raise ClanError( + f"Keymap '{system_config.keymap}' is not a valid keymap. " + f"Run 'clan flash --list-keymaps' to see a list of possible keymaps." + ) + system_config_nix["console"] = {"keyMap": system_config.keymap} + + if system_config.ssh_keys_path: + root_keys = [] + for key_path in map(lambda x: Path(x), system_config.ssh_keys_path): + try: + root_keys.append(key_path.read_text()) + except OSError as e: + raise ClanError(f"Cannot read SSH public key file: {key_path}: {e}") + system_config_nix["users"] = { + "users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}} + } + secret_facts_module = importlib.import_module(machine.secret_facts_module) secret_facts_store: SecretStoreBase = secret_facts_module.SecretStore( machine=machine @@ -76,7 +158,7 @@ def flash_machine( disko_install.extend( [ "--system-config", - json.dumps(system_config), + json.dumps(system_config_nix), ] ) disko_install.extend(["--option", "dry-run", "true"]) @@ -94,15 +176,13 @@ class FlashOptions: flake: FlakeId machine: str disks: dict[str, str] - ssh_keys_path: list[Path] dry_run: bool confirm: bool debug: bool mode: str - language: str - keymap: str write_efi_boot_entries: bool nix_options: list[str] + system_config: SystemConfig class AppendDiskAction(argparse.Action): @@ -126,17 +206,29 @@ def flash_command(args: argparse.Namespace) -> None: flake=args.flake, machine=args.machine, disks=args.disk, - ssh_keys_path=args.ssh_pubkey, dry_run=args.dry_run, confirm=not args.yes, debug=args.debug, mode=args.mode, - language=args.language, - keymap=args.keymap, + system_config=SystemConfig( + language=args.language, + keymap=args.keymap, + ssh_keys_path=args.ssh_pubkey, + ), write_efi_boot_entries=args.write_efi_boot_entries, nix_options=args.option, ) + if args.list_languages: + for language in list_possible_languages(): + print(language) + return + + if args.list_keymaps: + for keymap in list_possible_keymaps(): + print(keymap) + return + machine = Machine(opts.machine, flake=opts.flake) if opts.confirm and not opts.dry_run: disk_str = ", ".join(f"{name}={device}" for name, device in opts.disks.items()) @@ -148,28 +240,11 @@ def flash_command(args: argparse.Namespace) -> None: if ask != "y": return - extra_config: dict[str, Any] = {} - if opts.ssh_keys_path: - root_keys = [] - for key_path in opts.ssh_keys_path: - try: - root_keys.append(key_path.read_text()) - except OSError as e: - raise ClanError(f"Cannot read SSH public key file: {key_path}: {e}") - extra_config["users"] = { - "users": {"root": {"openssh": {"authorizedKeys": {"keys": root_keys}}}} - } - if opts.keymap: - extra_config["console"] = {"keyMap": opts.keymap} - - if opts.language: - extra_config["i18n"] = {"defaultLocale": opts.language} - flash_machine( machine, mode=opts.mode, disks=opts.disks, - system_config=extra_config, + system_config=opts.system_config, dry_run=opts.dry_run, debug=opts.debug, write_efi_boot_entries=opts.write_efi_boot_entries, @@ -221,6 +296,18 @@ def register_parser(parser: argparse.ArgumentParser) -> None: type=str, help="system language", ) + parser.add_argument( + "--list-languages", + help="List possible languages", + default=False, + action="store_true", + ) + parser.add_argument( + "--list-keymaps", + help="List possible keymaps", + default=False, + action="store_true", + ) parser.add_argument( "--keymap", type=str, diff --git a/pkgs/clan-cli/tests/test_deserializers.py b/pkgs/clan-cli/tests/test_deserializers.py index e858b5398..24da254cb 100644 --- a/pkgs/clan-cli/tests/test_deserializers.py +++ b/pkgs/clan-cli/tests/test_deserializers.py @@ -19,6 +19,7 @@ from clan_cli.inventory import ( ServiceBorgbackupRoleServer, ServiceMeta, ) +from clan_cli.machines import machines def test_simple() -> None: @@ -73,6 +74,55 @@ def test_nested() -> None: assert from_dict(Person, person_dict) == expected_person +def test_nested_nullable() -> None: + @dataclass + class SystemConfig: + language: str | None = field(default=None) + keymap: str | None = field(default=None) + ssh_keys_path: list[str] | None = field(default=None) + + @dataclass + class FlashOptions: + machine: machines.Machine + mode: str + disks: dict[str, str] + system_config: SystemConfig + dry_run: bool + write_efi_boot_entries: bool + debug: bool + + data = { + "machine": { + "name": "flash-installer", + "flake": {"loc": "git+https://git.clan.lol/clan/clan-core"}, + }, + "mode": "format", + "disks": {"main": "/dev/sda"}, + "system_config": {"language": "en_US.utf-8", "keymap": "en"}, + "dry_run": False, + "write_efi_boot_entries": False, + "debug": False, + "op_key": "jWnTSHwYhSgr7Qz3u4ppD", + } + + expected = FlashOptions( + machine=machines.Machine( + name="flash-installer", + flake=machines.FlakeId("git+https://git.clan.lol/clan/clan-core"), + ), + mode="format", + disks={"main": "/dev/sda"}, + system_config=SystemConfig( + language="en_US.utf-8", keymap="en", ssh_keys_path=None + ), + dry_run=False, + write_efi_boot_entries=False, + debug=False, + ) + + assert from_dict(FlashOptions, data) == expected + + def test_simple_field_missing() -> None: @dataclass class Person: diff --git a/pkgs/clan-vm-manager/clan_vm_manager/components/executor.py b/pkgs/clan-vm-manager/clan_vm_manager/components/executor.py index 7ca59d52f..b97013c50 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/components/executor.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/components/executor.py @@ -1,19 +1,14 @@ +import dataclasses import logging +import multiprocessing as mp import os import signal import sys import traceback +from collections.abc import Callable from pathlib import Path from typing import Any -import gi - -gi.require_version("GdkPixbuf", "2.0") - -import dataclasses -import multiprocessing as mp -from collections.abc import Callable - log = logging.getLogger(__name__) diff --git a/pkgs/webview-ui/.vscode/settings.json b/pkgs/webview-ui/.vscode/settings.json index 335c251a2..f9c1a6bc2 100644 --- a/pkgs/webview-ui/.vscode/settings.json +++ b/pkgs/webview-ui/.vscode/settings.json @@ -1,5 +1,7 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "tailwindCSS.experimental.classRegex": [["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]], - "editor.wordWrap": "on" + "typescript.tsdk": "node_modules/typescript/lib", + "tailwindCSS.experimental.classRegex": [ + ["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"] + ], + "editor.wordWrap": "on" } diff --git a/pkgs/webview-ui/app/README.md b/pkgs/webview-ui/app/README.md index 6a1764536..f171a99cd 100644 --- a/pkgs/webview-ui/app/README.md +++ b/pkgs/webview-ui/app/README.md @@ -1,8 +1,10 @@ ## Usage -Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. +Those templates dependencies are maintained via [pnpm](https://pnpm.io) via +`pnpm up -Lri`. -This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. +This is the reason you see a `pnpm-lock.yaml`. That being said, any package +manager will work. This file can be safely be removed once you clone a template. ```bash $ npm install # or pnpm install or yarn install @@ -16,19 +18,20 @@ In the project directory, you can run: ### `npm run dev` or `npm start` -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. +Runs the app in the development mode.
Open +[http://localhost:3000](http://localhost:3000) to view it in the browser. The page will reload if you make edits.
### `npm run build` -Builds the app for production to the `dist` folder.
-It correctly bundles Solid in production mode and optimizes the build for the best performance. +Builds the app for production to the `dist` folder.
It correctly bundles +Solid in production mode and optimizes the build for the best performance. -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! +The build is minified and the filenames include the hashes.
Your app is +ready to be deployed! ## Deployment -You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) +You can deploy the `dist` folder to any static host provider (netlify, surge, +now, etc.) diff --git a/pkgs/webview-ui/app/gtk.webview.js b/pkgs/webview-ui/app/gtk.webview.js index 522d93cc6..a768e1b5e 100644 --- a/pkgs/webview-ui/app/gtk.webview.js +++ b/pkgs/webview-ui/app/gtk.webview.js @@ -43,7 +43,7 @@ fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => { console.log(`Rewriting CSS url(): ${asset.url} to ${res}`); return res; }, - }) + }), ) .process(css, { from: `dist/${cssEntry}`, diff --git a/pkgs/webview-ui/app/src/App.tsx b/pkgs/webview-ui/app/src/App.tsx index 016cff09f..ea0b2742f 100644 --- a/pkgs/webview-ui/app/src/App.tsx +++ b/pkgs/webview-ui/app/src/App.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, type Component } from "solid-js"; +import { type Component, createEffect, createSignal } from "solid-js"; import { Layout } from "./layout/layout"; import { Route, Router } from "./Routes"; import { Toaster } from "solid-toast"; @@ -18,7 +18,7 @@ const [activeURI, setActiveURI] = makePersisted( { name: "activeURI", storage: localStorage, - } + }, ); export { activeURI, setActiveURI }; diff --git a/pkgs/webview-ui/app/src/api.ts b/pkgs/webview-ui/app/src/api.ts index dbe19ad9b..820b28b76 100644 --- a/pkgs/webview-ui/app/src/api.ts +++ b/pkgs/webview-ui/app/src/api.ts @@ -58,11 +58,11 @@ const registry: ObserverRegistry = operationNames.reduce( ...acc, [opName]: {}, }), - {} as ObserverRegistry + {} as ObserverRegistry, ); function createFunctions( - operationName: K + operationName: K, ): { dispatch: (args: OperationArgs) => void; receive: (fn: (response: OperationResponse) => void, id: string) => void; @@ -104,7 +104,7 @@ function download(filename: string, text: string) { const element = document.createElement("a"); element.setAttribute( "href", - "data:text/plain;charset=utf-8," + encodeURIComponent(text) + "data:text/plain;charset=utf-8," + encodeURIComponent(text), ); element.setAttribute("download", filename); @@ -118,7 +118,7 @@ function download(filename: string, text: string) { export const callApi = ( method: K, - args: OperationArgs + args: OperationArgs, ) => { return new Promise>((resolve, reject) => { const id = nanoid(); @@ -134,21 +134,19 @@ export const callApi = ( }); }; -const deserialize = - (fn: (response: T) => void) => - (str: string) => { - try { - const r = JSON.parse(str) as T; - console.log("Received: ", r); - fn(r); - } catch (e) { - console.log("Error parsing JSON: ", e); - window.localStorage.setItem("error", str); - console.error(str); - console.error("See localStorage 'error'"); - alert(`Error parsing JSON: ${e}`); - } - }; +const deserialize = (fn: (response: T) => void) => (str: string) => { + try { + const r = JSON.parse(str) as T; + console.log("Received: ", r); + fn(r); + } catch (e) { + console.log("Error parsing JSON: ", e); + window.localStorage.setItem("error", str); + console.error(str); + console.error("See localStorage 'error'"); + alert(`Error parsing JSON: ${e}`); + } +}; // Create the API object diff --git a/pkgs/webview-ui/app/src/components/MachineListItem.tsx b/pkgs/webview-ui/app/src/components/MachineListItem.tsx index 05ee9b61f..b53c55ae5 100644 --- a/pkgs/webview-ui/app/src/components/MachineListItem.tsx +++ b/pkgs/webview-ui/app/src/components/MachineListItem.tsx @@ -1,5 +1,5 @@ -import { Match, Show, Switch, createSignal } from "solid-js"; -import { ErrorData, SuccessData, pyApi } from "../api"; +import { createSignal, Match, Show, Switch } from "solid-js"; +import { ErrorData, pyApi, SuccessData } from "../api"; type MachineDetails = SuccessData<"list_machines">["data"][string]; @@ -94,21 +94,18 @@ export const MachineListItem = (props: MachineListItemProps) => {
System: - {hwInfo()[name]?.system ? ( - {hwInfo()[name]?.system} - ) : ( - Not set - )} + {hwInfo()[name]?.system + ? {hwInfo()[name]?.system} + : Not set}
Target Host: - {deploymentInfo()[name] ? ( - {deploymentInfo()[name]} - ) : ( - Not set - )} - {/* {deploymentInfo()[name]} + : Not set} + { + /*
}> @@ -119,7 +116,8 @@ export const MachineListItem = (props: MachineListItemProps) => { } > {(i) => + i()} - */} + */ + }
{/* Show only the first error at the bottom */} diff --git a/pkgs/webview-ui/app/src/floating/index.tsx b/pkgs/webview-ui/app/src/floating/index.tsx index 96267c82f..71ff78bbb 100644 --- a/pkgs/webview-ui/app/src/floating/index.tsx +++ b/pkgs/webview-ui/app/src/floating/index.tsx @@ -13,9 +13,9 @@ export interface UseFloatingOptions< whileElementsMounted?: ( reference: R, floating: F, - update: () => void - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - ) => void | (() => void); + update: () => void, + ) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + void | (() => void); } interface UseFloatingState extends Omit { @@ -30,7 +30,7 @@ export interface UseFloatingResult extends UseFloatingState { export function useFloating( reference: () => R | undefined | null, floating: () => F | undefined | null, - options?: UseFloatingOptions + options?: UseFloatingOptions, ): UseFloatingResult { const placement = () => options?.placement ?? "bottom"; const strategy = () => options?.strategy ?? "absolute"; @@ -77,7 +77,7 @@ export function useFloating( }, (err) => { setError(err); - } + }, ); } } @@ -95,7 +95,7 @@ export function useFloating( const cleanup = options.whileElementsMounted( currentReference, currentFloating, - update + update, ); if (cleanup) { diff --git a/pkgs/webview-ui/app/src/index.tsx b/pkgs/webview-ui/app/src/index.tsx index 4cc82a85d..6dff3cd3a 100644 --- a/pkgs/webview-ui/app/src/index.tsx +++ b/pkgs/webview-ui/app/src/index.tsx @@ -13,7 +13,7 @@ window.clan = window.clan || {}; if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error( - "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?" + "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", ); } @@ -30,5 +30,5 @@ render( ), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - root! + root!, ); diff --git a/pkgs/webview-ui/app/src/layout/layout.tsx b/pkgs/webview-ui/app/src/layout/layout.tsx index 616faa57d..c1a48497e 100644 --- a/pkgs/webview-ui/app/src/layout/layout.tsx +++ b/pkgs/webview-ui/app/src/layout/layout.tsx @@ -32,7 +32,8 @@ export const Layout: Component = (props) => { for="toplevel-drawer" aria-label="close sidebar" class="drawer-overlay" - > + > + diff --git a/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx b/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx index 31b035f32..f22170791 100644 --- a/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx +++ b/pkgs/webview-ui/app/src/routes/blockdevices/view.tsx @@ -27,35 +27,35 @@ export const BlockDevicesView: Component = () => {
- {isFetching ? ( - - ) : ( - - {(devices) => ( - - {(device) => ( -
-
-
Name
-
- {" "} - storage{" "} - {device.name} + {isFetching + ? + : ( + + {(devices) => ( + + {(device) => ( +
+
+
Name
+
+ {" "} + storage{" "} + {device.name} +
+
-
-
-
-
Size
-
{device.size}
-
+
+
Size
+
{device.size}
+
+
-
- )} - - )} - - )} + )} + + )} + + )}
); diff --git a/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx b/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx index fa6e872fe..fec7a8891 100644 --- a/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx +++ b/pkgs/webview-ui/app/src/routes/clan/clanDetails.tsx @@ -1,10 +1,10 @@ -import { OperationResponse, callApi, pyApi } from "@/src/api"; +import { callApi, OperationResponse, pyApi } from "@/src/api"; import { Show } from "solid-js"; import { - SubmitHandler, createForm, required, reset, + SubmitHandler, } from "@modular-forms/solid"; import toast from "solid-toast"; import { setActiveURI, setRoute } from "@/src/App"; @@ -43,7 +43,7 @@ export const ClanForm = () => { (async () => { await callApi("create_clan", { options: { - directory: target_dir, + directory: target_dir[0], template_url, initial: { meta, @@ -52,14 +52,14 @@ export const ClanForm = () => { }, }, }); - setActiveURI(target_dir); + setActiveURI(target_dir[0]); setRoute("machines"); })(), { loading: "Creating clan...", success: "Clan Successfully Created", error: "Failed to create clan", - } + }, ); reset(formStore); }; diff --git a/pkgs/webview-ui/app/src/routes/clan/editClan.tsx b/pkgs/webview-ui/app/src/routes/clan/editClan.tsx index 6494d1e7d..fc437c409 100644 --- a/pkgs/webview-ui/app/src/routes/clan/editClan.tsx +++ b/pkgs/webview-ui/app/src/routes/clan/editClan.tsx @@ -1,10 +1,10 @@ -import { OperationResponse, callApi, pyApi } from "@/src/api"; -import { Accessor, Show, Switch, Match } from "solid-js"; +import { callApi, OperationResponse, pyApi } from "@/src/api"; +import { Accessor, Match, Show, Switch } from "solid-js"; import { - SubmitHandler, createForm, required, reset, + SubmitHandler, } from "@modular-forms/solid"; import toast from "solid-toast"; import { createQuery } from "@tanstack/solid-query"; @@ -65,7 +65,7 @@ export const FinalEditClanForm = (props: FinalEditClanFormProps) => { loading: "Updating clan...", success: "Clan Successfully updated", error: "Failed to update clan", - } + }, ); props.done(); }; diff --git a/pkgs/webview-ui/app/src/routes/flash/view.tsx b/pkgs/webview-ui/app/src/routes/flash/view.tsx index 31a2275bc..ca40137dc 100644 --- a/pkgs/webview-ui/app/src/routes/flash/view.tsx +++ b/pkgs/webview-ui/app/src/routes/flash/view.tsx @@ -1,24 +1,24 @@ import { route } from "@/src/App"; -import { OperationArgs, OperationResponse, callApi, pyApi } from "@/src/api"; -import { SubmitHandler, createForm, required } from "@modular-forms/solid"; +import { callApi, OperationArgs, OperationResponse, pyApi } from "@/src/api"; +import { + createForm, + required, + setValue, + SubmitHandler, +} from "@modular-forms/solid"; import { createQuery } from "@tanstack/solid-query"; -import { For, createSignal } from "solid-js"; +import { createEffect, createSignal, For } from "solid-js"; import { effect } from "solid-js/web"; -// type FlashMachineArgs = { -// machine: Omit["machine"], "cached_deployment">; -// } & Omit, "machine">, "system_config">; - -// type FlashMachineArgs = OperationArgs<"flash_machine">; - -// type k = keyof FlashMachineArgs; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions type FlashFormValues = { machine: { - name: string; + devicePath: string; flake: string; }; disk: string; + language: string; + keymap: string; + sshKeys: string[]; }; type BlockDevices = Extract< @@ -28,10 +28,39 @@ type BlockDevices = Extract< export const Flash = () => { const [formStore, { Form, Field }] = createForm({}); + const [sshKeys, setSshKeys] = createSignal([]); + const [isFlashing, setIsFlashing] = createSignal(false); + + const selectSshPubkey = async () => { + try { + const loc = await callApi("open_file", { + file_request: { + title: "Select SSH Key", + mode: "open_multiple_files", + filters: { patterns: ["*.pub"] }, + initial_folder: "~/.ssh", + }, + }); + console.log({ loc }, loc.status); + if (loc.status === "success" && loc.data) { + setSshKeys(loc.data); + return loc.data; + } + } catch (e) { + // + } + }; + + // Create an effect that updates the form when externalUsername changes + createEffect(() => { + const newSshKeys = sshKeys(); + if (newSshKeys) { + setValue(formStore, "sshKeys", newSshKeys); + } + }); const { data: devices, - refetch: loadDevices, isFetching, } = createQuery(() => ({ queryKey: ["block_devices"], @@ -40,20 +69,61 @@ export const Flash = () => { if (result.status === "error") throw new Error("Failed to fetch data"); return result.data; }, - staleTime: 1000 * 60 * 1, // 1 minutes + staleTime: 1000 * 60 * 2, // 1 minutes + })); + + const { + data: keymaps, + isFetching: isFetchingKeymaps, + } = createQuery(() => ({ + queryKey: ["list_keymaps"], + queryFn: async () => { + const result = await callApi("list_possible_keymaps", {}); + if (result.status === "error") throw new Error("Failed to fetch data"); + return result.data; + }, + staleTime: 1000 * 60 * 15, // 15 minutes + })); + + const { + data: languages, + isFetching: isFetchingLanguages, + } = createQuery(() => ({ + queryKey: ["list_languages"], + queryFn: async () => { + const result = await callApi("list_possible_languages", {}); + if (result.status === "error") throw new Error("Failed to fetch data"); + return result.data; + }, + staleTime: 1000 * 60 * 15, // 15 minutes })); const handleSubmit = async (values: FlashFormValues) => { - // TODO: Rework Flash machine API - // Its unusable in its current state - // await callApi("flash_machine", { - // machine: { - // name: "", - // }, - // disks: {values.disk }, - // dry_run: true, - // }); - console.log("submit", values); + setIsFlashing(true); + try { + await callApi("flash_machine", { + machine: { + name: values.machine.devicePath, + flake: { + loc: values.machine.flake, + }, + }, + mode: "format", + disks: { "main": values.disk }, + system_config: { + language: values.language, + keymap: values.keymap, + ssh_keys_path: values.sshKeys, + }, + dry_run: false, + write_efi_boot_entries: false, + debug: false, + }); + } catch (error) { + console.error("Error submitting form:", error); + } finally { + setIsFlashing(false); + } }; return ( @@ -70,7 +140,8 @@ export const Flash = () => { @@ -86,7 +157,7 @@ export const Flash = () => { )} {(field, props) => ( @@ -96,7 +167,7 @@ export const Flash = () => { @@ -120,13 +191,11 @@ export const Flash = () => { class="select select-bordered w-full" {...props} > - {/* devices */} - - + {(device) => ( - )} @@ -147,8 +216,106 @@ export const Flash = () => { )} -
diff --git a/pkgs/webview-ui/app/src/routes/hosts/view.tsx b/pkgs/webview-ui/app/src/routes/hosts/view.tsx index 68c02e010..bb75162fa 100644 --- a/pkgs/webview-ui/app/src/routes/hosts/view.tsx +++ b/pkgs/webview-ui/app/src/routes/hosts/view.tsx @@ -1,9 +1,9 @@ import { - For, - Show, + type Component, createEffect, createSignal, - type Component, + For, + Show, } from "solid-js"; import { route } from "@/src/App"; import { OperationResponse, pyApi } from "@/src/api"; diff --git a/pkgs/webview-ui/app/src/routes/machines/view.tsx b/pkgs/webview-ui/app/src/routes/machines/view.tsx index 441fe755e..87b8b28c1 100644 --- a/pkgs/webview-ui/app/src/routes/machines/view.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/view.tsx @@ -1,14 +1,14 @@ import { + type Component, + createEffect, + createSignal, For, Match, Show, Switch, - createEffect, - createSignal, - type Component, } from "solid-js"; import { activeURI, route, setActiveURI, setRoute } from "@/src/App"; -import { OperationResponse, callApi, pyApi } from "@/src/api"; +import { callApi, OperationResponse, pyApi } from "@/src/api"; import toast from "solid-toast"; import { MachineListItem } from "@/src/components/MachineListItem"; @@ -91,7 +91,8 @@ export const MachineListView: Component = () => { add - {/* + { + /* {(services) => ( {(service) => ( @@ -137,7 +138,8 @@ export const MachineListView: Component = () => { )} )} - */} + */ + } {/* Loading skeleton */} diff --git a/pkgs/webview-ui/app/src/routes/settings/index.tsx b/pkgs/webview-ui/app/src/routes/settings/index.tsx index 8fa7869f7..8aae735fe 100644 --- a/pkgs/webview-ui/app/src/routes/settings/index.tsx +++ b/pkgs/webview-ui/app/src/routes/settings/index.tsx @@ -1,16 +1,16 @@ import { callApi } from "@/src/api"; import { - SubmitHandler, createForm, required, setValue, + SubmitHandler, } from "@modular-forms/solid"; import { activeURI, - setClanList, - setActiveURI, - setRoute, clanList, + setActiveURI, + setClanList, + setRoute, } from "@/src/App"; import { createEffect, @@ -140,7 +140,7 @@ const ClanDetails = (props: ClanDetailsProps) => { s.filter((v, idx) => { if (v == clan_dir) { setActiveURI( - clanList()[idx - 1] || clanList()[idx + 1] || null + clanList()[idx - 1] || clanList()[idx + 1] || null, ); return false; } diff --git a/pkgs/webview-ui/app/tests/types.test.ts b/pkgs/webview-ui/app/tests/types.test.ts index f25455e3e..5e8fbeaa3 100644 --- a/pkgs/webview-ui/app/tests/types.test.ts +++ b/pkgs/webview-ui/app/tests/types.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expectTypeOf } from "vitest"; +import { describe, expectTypeOf, it } from "vitest"; import { OperationNames, pyApi } from "@/src/api"; @@ -44,13 +44,13 @@ describe.concurrent("API types work properly", () => { .parameter(0) .toMatchTypeOf< | { - status: "success"; - data: { - machine_name: string; - machine_icon?: string | null; - machine_description?: string | null; - }; - } + status: "success"; + data: { + machine_name: string; + machine_icon?: string | null; + machine_description?: string | null; + }; + } | { status: "error"; errors: any } >(); }); diff --git a/pkgs/webview-ui/app/tsconfig.json b/pkgs/webview-ui/app/tsconfig.json index adaab944c..80d8db30d 100644 --- a/pkgs/webview-ui/app/tsconfig.json +++ b/pkgs/webview-ui/app/tsconfig.json @@ -14,6 +14,7 @@ "allowJs": true, "isolatedModules": true, "paths": { - "@/*": ["./*"]} - }, + "@/*": ["./*"] + } + } } diff --git a/pkgs/webview-ui/app/util.ts b/pkgs/webview-ui/app/util.ts index eaeeab027..93d9f220a 100644 --- a/pkgs/webview-ui/app/util.ts +++ b/pkgs/webview-ui/app/util.ts @@ -20,8 +20,7 @@ export function isValidHostname(value: string | null | undefined) { const isValid = labels.every(function (label) { const validLabelChars = /^([a-zA-Z0-9-]+)$/g; - const validLabel = - validLabelChars.test(label) && + const validLabel = validLabelChars.test(label) && label.length < 64 && !label.startsWith("-") && !label.endsWith("-");