clan-app: Finish flash view. clan-cli: Flash cli now verifies if language and keymap are valid.

This commit is contained in:
Qubasa
2024-08-02 17:51:45 +02:00
parent f2e697f3e4
commit 3e9ebbc90f
27 changed files with 556 additions and 202 deletions

View File

@@ -4,6 +4,8 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
import logging import logging
from pathlib import Path
from typing import Any
from clan_cli.api import ErrorDataClass, SuccessDataClass from clan_cli.api import ErrorDataClass, SuccessDataClass
from clan_cli.api.directory import FileRequest 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, # This implements the abstract function open_file with one argument, file_request,
# which is a FileRequest object and returns a string or None. # which is a FileRequest object and returns a string or None.
class open_file( class open_file(
ImplFunc[[FileRequest, str], SuccessDataClass[str | None] | ErrorDataClass] ImplFunc[[FileRequest, str], SuccessDataClass[list[str] | None] | ErrorDataClass]
): ):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -27,7 +29,7 @@ class open_file(
try: try:
gfile = file_dialog.open_finish(task) gfile = file_dialog.open_finish(task)
if gfile: if gfile:
selected_path = gfile.get_path() selected_path = [gfile.get_path()]
self.returns( self.returns(
SuccessDataClass( SuccessDataClass(
op_key=op_key, data=selected_path, status="success" op_key=op_key, data=selected_path, status="success"
@@ -36,11 +38,26 @@ class open_file(
except Exception as e: except Exception as e:
print(f"Error getting selected file or directory: {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: def on_folder_select(file_dialog: Gtk.FileDialog, task: Gio.Task) -> None:
try: try:
gfile = file_dialog.select_folder_finish(task) gfile = file_dialog.select_folder_finish(task)
if gfile: if gfile:
selected_path = gfile.get_path() selected_path = [gfile.get_path()]
self.returns( self.returns(
SuccessDataClass( SuccessDataClass(
op_key=op_key, data=selected_path, status="success" op_key=op_key, data=selected_path, status="success"
@@ -53,7 +70,7 @@ class open_file(
try: try:
gfile = file_dialog.save_finish(task) gfile = file_dialog.save_finish(task)
if gfile: if gfile:
selected_path = gfile.get_path() selected_path = [gfile.get_path()]
self.returns( self.returns(
SuccessDataClass( SuccessDataClass(
op_key=op_key, data=selected_path, status="success" op_key=op_key, data=selected_path, status="success"
@@ -90,9 +107,21 @@ class open_file(
filters.append(file_filters) filters.append(file_filters)
dialog.set_filters(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 select_folder
if file_request.mode == "select_folder": if file_request.mode == "select_folder":
dialog.select_folder(callback=on_folder_select) 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": elif file_request.mode == "open_file":
dialog.open(callback=on_file_select) dialog.open(callback=on_file_select)
elif file_request.mode == "save": elif file_request.mode == "save":

View File

@@ -1,4 +1,5 @@
import logging import logging
import os
import gi import gi
from clan_cli.api import API from clan_cli.api import API
@@ -47,3 +48,4 @@ class MainWindow(Adw.ApplicationWindow):
def on_destroy(self, source: "Adw.ApplicationWindow") -> None: def on_destroy(self, source: "Adw.ApplicationWindow") -> None:
log.debug("Destroying Adw.ApplicationWindow") log.debug("Destroying Adw.ApplicationWindow")
os._exit(0)

View File

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

View File

@@ -12,24 +12,26 @@ from . import API
@dataclass @dataclass
class FileFilter: class FileFilter:
title: str | None title: str | None = field(default=None)
mime_types: list[str] | None mime_types: list[str] | None = field(default=None)
patterns: list[str] | None patterns: list[str] | None = field(default=None)
suffixes: list[str] | None suffixes: list[str] | None = field(default=None)
@dataclass @dataclass
class FileRequest: class FileRequest:
# Mode of the os dialog window # 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 of the os dialog window
title: str | None = None title: str | None = field(default=None)
# Pre-applied filters for the file dialog # 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 @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. 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. 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 @dataclass
class BlkInfo: class BlkInfo:
name: str name: str
path: str
rm: str rm: str
size: str size: str
ro: bool ro: bool
@@ -103,6 +106,7 @@ class Blockdevices:
def blk_from_dict(data: dict) -> BlkInfo: def blk_from_dict(data: dict) -> BlkInfo:
return BlkInfo( return BlkInfo(
name=data["name"], name=data["name"],
path=data["path"],
rm=data["rm"], rm=data["rm"],
size=data["size"], size=data["size"],
ro=data["ro"], ro=data["ro"],
@@ -117,7 +121,10 @@ def show_block_devices() -> Blockdevices:
Abstract api method to show block devices. Abstract api method to show block devices.
It must return a list of 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) proc = run_no_stdout(cmd)
res = proc.stdout.strip() res = proc.stdout.strip()

View File

@@ -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: if field_name not in field_values:
formatted_path = " ".join(path) formatted_path = " ".join(path)
raise ClanError( 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 return t(**field_values) # type: ignore

View File

@@ -6,7 +6,7 @@ import os
import shutil import shutil
import textwrap import textwrap
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any from typing import Any
@@ -19,23 +19,105 @@ from .completions import add_dynamic_completer, complete_machines
from .errors import ClanError from .errors import ClanError
from .facts.secret_modules import SecretStoreBase from .facts.secret_modules import SecretStoreBase
from .machines.machines import Machine from .machines.machines import Machine
from .nix import nix_shell from .nix import nix_build, nix_shell
log = logging.getLogger(__name__) 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 @API.register
def flash_machine( def flash_machine(
machine: Machine, machine: Machine,
*, *,
mode: str, mode: str,
disks: dict[str, str], disks: dict[str, str],
system_config: dict[str, Any], system_config: SystemConfig,
dry_run: bool, dry_run: bool,
write_efi_boot_entries: bool, write_efi_boot_entries: bool,
debug: bool, debug: bool,
extra_args: list[str] = [], extra_args: list[str] = [],
) -> None: ) -> 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_module = importlib.import_module(machine.secret_facts_module)
secret_facts_store: SecretStoreBase = secret_facts_module.SecretStore( secret_facts_store: SecretStoreBase = secret_facts_module.SecretStore(
machine=machine machine=machine
@@ -76,7 +158,7 @@ def flash_machine(
disko_install.extend( disko_install.extend(
[ [
"--system-config", "--system-config",
json.dumps(system_config), json.dumps(system_config_nix),
] ]
) )
disko_install.extend(["--option", "dry-run", "true"]) disko_install.extend(["--option", "dry-run", "true"])
@@ -94,15 +176,13 @@ class FlashOptions:
flake: FlakeId flake: FlakeId
machine: str machine: str
disks: dict[str, str] disks: dict[str, str]
ssh_keys_path: list[Path]
dry_run: bool dry_run: bool
confirm: bool confirm: bool
debug: bool debug: bool
mode: str mode: str
language: str
keymap: str
write_efi_boot_entries: bool write_efi_boot_entries: bool
nix_options: list[str] nix_options: list[str]
system_config: SystemConfig
class AppendDiskAction(argparse.Action): class AppendDiskAction(argparse.Action):
@@ -126,17 +206,29 @@ def flash_command(args: argparse.Namespace) -> None:
flake=args.flake, flake=args.flake,
machine=args.machine, machine=args.machine,
disks=args.disk, disks=args.disk,
ssh_keys_path=args.ssh_pubkey,
dry_run=args.dry_run, dry_run=args.dry_run,
confirm=not args.yes, confirm=not args.yes,
debug=args.debug, debug=args.debug,
mode=args.mode, mode=args.mode,
language=args.language, system_config=SystemConfig(
keymap=args.keymap, language=args.language,
keymap=args.keymap,
ssh_keys_path=args.ssh_pubkey,
),
write_efi_boot_entries=args.write_efi_boot_entries, write_efi_boot_entries=args.write_efi_boot_entries,
nix_options=args.option, 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) machine = Machine(opts.machine, flake=opts.flake)
if opts.confirm and not opts.dry_run: if opts.confirm and not opts.dry_run:
disk_str = ", ".join(f"{name}={device}" for name, device in opts.disks.items()) 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": if ask != "y":
return 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( flash_machine(
machine, machine,
mode=opts.mode, mode=opts.mode,
disks=opts.disks, disks=opts.disks,
system_config=extra_config, system_config=opts.system_config,
dry_run=opts.dry_run, dry_run=opts.dry_run,
debug=opts.debug, debug=opts.debug,
write_efi_boot_entries=opts.write_efi_boot_entries, write_efi_boot_entries=opts.write_efi_boot_entries,
@@ -221,6 +296,18 @@ def register_parser(parser: argparse.ArgumentParser) -> None:
type=str, type=str,
help="system language", 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( parser.add_argument(
"--keymap", "--keymap",
type=str, type=str,

View File

@@ -19,6 +19,7 @@ from clan_cli.inventory import (
ServiceBorgbackupRoleServer, ServiceBorgbackupRoleServer,
ServiceMeta, ServiceMeta,
) )
from clan_cli.machines import machines
def test_simple() -> None: def test_simple() -> None:
@@ -73,6 +74,55 @@ def test_nested() -> None:
assert from_dict(Person, person_dict) == expected_person 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: def test_simple_field_missing() -> None:
@dataclass @dataclass
class Person: class Person:

View File

@@ -1,19 +1,14 @@
import dataclasses
import logging import logging
import multiprocessing as mp
import os import os
import signal import signal
import sys import sys
import traceback import traceback
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any 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__) log = logging.getLogger(__name__)

View File

@@ -1,5 +1,7 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]], "tailwindCSS.experimental.classRegex": [
"editor.wordWrap": "on" ["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
],
"editor.wordWrap": "on"
} }

View File

@@ -1,8 +1,10 @@
## Usage ## 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 ```bash
$ npm install # or pnpm install or yarn install $ 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` ### `npm run dev` or `npm start`
Runs the app in the development mode.<br> Runs the app in the development mode.<br> Open
Open [http://localhost:3000](http://localhost:3000) to view it in the browser. [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br> The page will reload if you make edits.<br>
### `npm run build` ### `npm run build`
Builds the app for production to the `dist` folder.<br> Builds the app for production to the `dist` folder.<br> It correctly bundles
It correctly bundles Solid in production mode and optimizes the build for the best performance. Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br> The build is minified and the filenames include the hashes.<br> Your app is
Your app is ready to be deployed! ready to be deployed!
## Deployment ## 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.)

View File

@@ -43,7 +43,7 @@ fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => {
console.log(`Rewriting CSS url(): ${asset.url} to ${res}`); console.log(`Rewriting CSS url(): ${asset.url} to ${res}`);
return res; return res;
}, },
}) }),
) )
.process(css, { .process(css, {
from: `dist/${cssEntry}`, from: `dist/${cssEntry}`,

View File

@@ -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 { Layout } from "./layout/layout";
import { Route, Router } from "./Routes"; import { Route, Router } from "./Routes";
import { Toaster } from "solid-toast"; import { Toaster } from "solid-toast";
@@ -18,7 +18,7 @@ const [activeURI, setActiveURI] = makePersisted(
{ {
name: "activeURI", name: "activeURI",
storage: localStorage, storage: localStorage,
} },
); );
export { activeURI, setActiveURI }; export { activeURI, setActiveURI };

View File

@@ -58,11 +58,11 @@ const registry: ObserverRegistry = operationNames.reduce(
...acc, ...acc,
[opName]: {}, [opName]: {},
}), }),
{} as ObserverRegistry {} as ObserverRegistry,
); );
function createFunctions<K extends OperationNames>( function createFunctions<K extends OperationNames>(
operationName: K operationName: K,
): { ): {
dispatch: (args: OperationArgs<K>) => void; dispatch: (args: OperationArgs<K>) => void;
receive: (fn: (response: OperationResponse<K>) => void, id: string) => void; receive: (fn: (response: OperationResponse<K>) => void, id: string) => void;
@@ -104,7 +104,7 @@ function download(filename: string, text: string) {
const element = document.createElement("a"); const element = document.createElement("a");
element.setAttribute( element.setAttribute(
"href", "href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text) "data:text/plain;charset=utf-8," + encodeURIComponent(text),
); );
element.setAttribute("download", filename); element.setAttribute("download", filename);
@@ -118,7 +118,7 @@ function download(filename: string, text: string) {
export const callApi = <K extends OperationNames>( export const callApi = <K extends OperationNames>(
method: K, method: K,
args: OperationArgs<K> args: OperationArgs<K>,
) => { ) => {
return new Promise<OperationResponse<K>>((resolve, reject) => { return new Promise<OperationResponse<K>>((resolve, reject) => {
const id = nanoid(); const id = nanoid();
@@ -134,21 +134,19 @@ export const callApi = <K extends OperationNames>(
}); });
}; };
const deserialize = const deserialize = <T>(fn: (response: T) => void) => (str: string) => {
<T>(fn: (response: T) => void) => try {
(str: string) => { const r = JSON.parse(str) as T;
try { console.log("Received: ", r);
const r = JSON.parse(str) as T; fn(r);
console.log("Received: ", r); } catch (e) {
fn(r); console.log("Error parsing JSON: ", e);
} catch (e) { window.localStorage.setItem("error", str);
console.log("Error parsing JSON: ", e); console.error(str);
window.localStorage.setItem("error", str); console.error("See localStorage 'error'");
console.error(str); alert(`Error parsing JSON: ${e}`);
console.error("See localStorage 'error'"); }
alert(`Error parsing JSON: ${e}`); };
}
};
// Create the API object // Create the API object

View File

@@ -1,5 +1,5 @@
import { Match, Show, Switch, createSignal } from "solid-js"; import { createSignal, Match, Show, Switch } from "solid-js";
import { ErrorData, SuccessData, pyApi } from "../api"; import { ErrorData, pyApi, SuccessData } from "../api";
type MachineDetails = SuccessData<"list_machines">["data"][string]; type MachineDetails = SuccessData<"list_machines">["data"][string];
@@ -94,21 +94,18 @@ export const MachineListItem = (props: MachineListItemProps) => {
<div class="flex flex-row flex-wrap gap-4 py-2"> <div class="flex flex-row flex-wrap gap-4 py-2">
<div class="badge badge-primary flex flex-row gap-1 py-4 align-middle"> <div class="badge badge-primary flex flex-row gap-1 py-4 align-middle">
<span>System:</span> <span>System:</span>
{hwInfo()[name]?.system ? ( {hwInfo()[name]?.system
<span class="text-primary">{hwInfo()[name]?.system}</span> ? <span class="text-primary">{hwInfo()[name]?.system}</span>
) : ( : <span class="text-warning">Not set</span>}
<span class="text-warning">Not set</span>
)}
</div> </div>
<div class="badge badge-ghost flex flex-row gap-1 py-4 align-middle"> <div class="badge badge-ghost flex flex-row gap-1 py-4 align-middle">
<span>Target Host:</span> <span>Target Host:</span>
{deploymentInfo()[name] ? ( {deploymentInfo()[name]
<span class="text-primary">{deploymentInfo()[name]}</span> ? <span class="text-primary">{deploymentInfo()[name]}</span>
) : ( : <span class="text-warning">Not set</span>}
<span class="text-warning">Not set</span> {
)} /* <Show
{/* <Show
when={deploymentInfo()[name]} when={deploymentInfo()[name]}
fallback={ fallback={
<Switch fallback={<div class="skeleton h-8 w-full"></div>}> <Switch fallback={<div class="skeleton h-8 w-full"></div>}>
@@ -119,7 +116,8 @@ export const MachineListItem = (props: MachineListItemProps) => {
} }
> >
{(i) => + i()} {(i) => + i()}
</Show> */} </Show> */
}
</div> </div>
</div> </div>
{/* Show only the first error at the bottom */} {/* Show only the first error at the bottom */}

View File

@@ -13,9 +13,9 @@ export interface UseFloatingOptions<
whileElementsMounted?: ( whileElementsMounted?: (
reference: R, reference: R,
floating: F, floating: F,
update: () => void update: () => void,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type ) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => void | (() => void); void | (() => void);
} }
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> { interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
@@ -30,7 +30,7 @@ export interface UseFloatingResult extends UseFloatingState {
export function useFloating<R extends ReferenceElement, F extends HTMLElement>( export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
reference: () => R | undefined | null, reference: () => R | undefined | null,
floating: () => F | undefined | null, floating: () => F | undefined | null,
options?: UseFloatingOptions<R, F> options?: UseFloatingOptions<R, F>,
): UseFloatingResult { ): UseFloatingResult {
const placement = () => options?.placement ?? "bottom"; const placement = () => options?.placement ?? "bottom";
const strategy = () => options?.strategy ?? "absolute"; const strategy = () => options?.strategy ?? "absolute";
@@ -77,7 +77,7 @@ export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
}, },
(err) => { (err) => {
setError(err); setError(err);
} },
); );
} }
} }
@@ -95,7 +95,7 @@ export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
const cleanup = options.whileElementsMounted( const cleanup = options.whileElementsMounted(
currentReference, currentReference,
currentFloating, currentFloating,
update update,
); );
if (cleanup) { if (cleanup) {

View File

@@ -13,7 +13,7 @@ window.clan = window.clan || {};
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error( 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(
</QueryClientProvider> </QueryClientProvider>
), ),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
root! root!,
); );

View File

@@ -32,7 +32,8 @@ export const Layout: Component<LayoutProps> = (props) => {
for="toplevel-drawer" for="toplevel-drawer"
aria-label="close sidebar" aria-label="close sidebar"
class="drawer-overlay" class="drawer-overlay"
></label> >
</label>
<Sidebar route={route} setRoute={setRoute} /> <Sidebar route={route} setRoute={setRoute} />
</div> </div>
</div> </div>

View File

@@ -27,35 +27,35 @@ export const BlockDevicesView: Component = () => {
</button> </button>
</div> </div>
<div class="flex max-w-screen-lg flex-col gap-4"> <div class="flex max-w-screen-lg flex-col gap-4">
{isFetching ? ( {isFetching
<span class="loading loading-bars"></span> ? <span class="loading loading-bars"></span>
) : ( : (
<Show when={devices}> <Show when={devices}>
{(devices) => ( {(devices) => (
<For each={devices().blockdevices}> <For each={devices().blockdevices}>
{(device) => ( {(device) => (
<div class="stats shadow"> <div class="stats shadow">
<div class="stat w-28 py-8"> <div class="stat w-28 py-8">
<div class="stat-title">Name</div> <div class="stat-title">Name</div>
<div class="stat-value"> <div class="stat-value">
{" "} {" "}
<span class="material-icons">storage</span>{" "} <span class="material-icons">storage</span>{" "}
{device.name} {device.name}
</div>
<div class="stat-desc"></div>
</div> </div>
<div class="stat-desc"></div>
</div>
<div class="stat w-28"> <div class="stat w-28">
<div class="stat-title">Size</div> <div class="stat-title">Size</div>
<div class="stat-value">{device.size}</div> <div class="stat-value">{device.size}</div>
<div class="stat-desc"></div> <div class="stat-desc"></div>
</div>
</div> </div>
</div> )}
)} </For>
</For> )}
)} </Show>
</Show> )}
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,10 @@
import { OperationResponse, callApi, pyApi } from "@/src/api"; import { callApi, OperationResponse, pyApi } from "@/src/api";
import { Show } from "solid-js"; import { Show } from "solid-js";
import { import {
SubmitHandler,
createForm, createForm,
required, required,
reset, reset,
SubmitHandler,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import toast from "solid-toast"; import toast from "solid-toast";
import { setActiveURI, setRoute } from "@/src/App"; import { setActiveURI, setRoute } from "@/src/App";
@@ -43,7 +43,7 @@ export const ClanForm = () => {
(async () => { (async () => {
await callApi("create_clan", { await callApi("create_clan", {
options: { options: {
directory: target_dir, directory: target_dir[0],
template_url, template_url,
initial: { initial: {
meta, meta,
@@ -52,14 +52,14 @@ export const ClanForm = () => {
}, },
}, },
}); });
setActiveURI(target_dir); setActiveURI(target_dir[0]);
setRoute("machines"); setRoute("machines");
})(), })(),
{ {
loading: "Creating clan...", loading: "Creating clan...",
success: "Clan Successfully Created", success: "Clan Successfully Created",
error: "Failed to create clan", error: "Failed to create clan",
} },
); );
reset(formStore); reset(formStore);
}; };

View File

@@ -1,10 +1,10 @@
import { OperationResponse, callApi, pyApi } from "@/src/api"; import { callApi, OperationResponse, pyApi } from "@/src/api";
import { Accessor, Show, Switch, Match } from "solid-js"; import { Accessor, Match, Show, Switch } from "solid-js";
import { import {
SubmitHandler,
createForm, createForm,
required, required,
reset, reset,
SubmitHandler,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import toast from "solid-toast"; import toast from "solid-toast";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
@@ -65,7 +65,7 @@ export const FinalEditClanForm = (props: FinalEditClanFormProps) => {
loading: "Updating clan...", loading: "Updating clan...",
success: "Clan Successfully updated", success: "Clan Successfully updated",
error: "Failed to update clan", error: "Failed to update clan",
} },
); );
props.done(); props.done();
}; };

View File

@@ -1,24 +1,24 @@
import { route } from "@/src/App"; import { route } from "@/src/App";
import { OperationArgs, OperationResponse, callApi, pyApi } from "@/src/api"; import { callApi, OperationArgs, OperationResponse, pyApi } from "@/src/api";
import { SubmitHandler, createForm, required } from "@modular-forms/solid"; import {
createForm,
required,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query"; 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"; import { effect } from "solid-js/web";
// type FlashMachineArgs = {
// machine: Omit<OperationArgs<"flash_machine">["machine"], "cached_deployment">;
// } & Omit<Omit<OperationArgs<"flash_machine">, "machine">, "system_config">;
// type FlashMachineArgs = OperationArgs<"flash_machine">;
// type k = keyof FlashMachineArgs;
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type FlashFormValues = { type FlashFormValues = {
machine: { machine: {
name: string; devicePath: string;
flake: string; flake: string;
}; };
disk: string; disk: string;
language: string;
keymap: string;
sshKeys: string[];
}; };
type BlockDevices = Extract< type BlockDevices = Extract<
@@ -28,10 +28,39 @@ type BlockDevices = Extract<
export const Flash = () => { export const Flash = () => {
const [formStore, { Form, Field }] = createForm<FlashFormValues>({}); const [formStore, { Form, Field }] = createForm<FlashFormValues>({});
const [sshKeys, setSshKeys] = createSignal<string[]>([]);
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 { const {
data: devices, data: devices,
refetch: loadDevices,
isFetching, isFetching,
} = createQuery(() => ({ } = createQuery(() => ({
queryKey: ["block_devices"], queryKey: ["block_devices"],
@@ -40,20 +69,61 @@ export const Flash = () => {
if (result.status === "error") throw new Error("Failed to fetch data"); if (result.status === "error") throw new Error("Failed to fetch data");
return result.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) => { const handleSubmit = async (values: FlashFormValues) => {
// TODO: Rework Flash machine API setIsFlashing(true);
// Its unusable in its current state try {
// await callApi("flash_machine", { await callApi("flash_machine", {
// machine: { machine: {
// name: "", name: values.machine.devicePath,
// }, flake: {
// disks: {values.disk }, loc: values.machine.flake,
// dry_run: true, },
// }); },
console.log("submit", values); 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 ( return (
@@ -70,7 +140,8 @@ export const Flash = () => {
<input <input
type="text" type="text"
class="grow" class="grow"
placeholder="machine.flake" //placeholder="machine.flake"
value="git+https://git.clan.lol/clan/clan-core"
required required
{...props} {...props}
/> />
@@ -86,7 +157,7 @@ export const Flash = () => {
)} )}
</Field> </Field>
<Field <Field
name="machine.name" name="machine.devicePath"
validate={[required("This field is required")]} validate={[required("This field is required")]}
> >
{(field, props) => ( {(field, props) => (
@@ -96,7 +167,7 @@ export const Flash = () => {
<input <input
type="text" type="text"
class="grow" class="grow"
placeholder="machine.name" value="flash-installer"
required required
{...props} {...props}
/> />
@@ -120,13 +191,11 @@ export const Flash = () => {
class="select select-bordered w-full" class="select select-bordered w-full"
{...props} {...props}
> >
{/* <span class="material-icons">devices</span> */} <option value="" disabled>Select a disk</option>
<option disabled>Select a disk</option>
<For each={devices?.blockdevices}> <For each={devices?.blockdevices}>
{(device) => ( {(device) => (
<option value={device.name}> <option value={device.path}>
{device.name} / {device.size} bytes {device.path} -- {device.size} bytes
</option> </option>
)} )}
</For> </For>
@@ -147,8 +216,106 @@ export const Flash = () => {
</> </>
)} )}
</Field> </Field>
<button class="btn btn-error" type="submit"> <Field name="language" validate={[required("This field is required")]}>
<span class="material-icons">bolt</span>Flash Installer {(field, props) => (
<>
<label class="form-control input-bordered flex w-full items-center gap-2">
<select
required
class="select select-bordered w-full"
{...props}
>
<option>en_US.UTF-8</option>
<For each={languages}>
{(language) => (
<option value={language}>
{language}
</option>
)}
</For>
</select>
<div class="label">
{isFetchingLanguages && (
<span class="label-text-alt">
<span class="loading loading-bars">en_US.UTF-8</span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</>
)}
</Field>
<Field name="keymap" validate={[required("This field is required")]}>
{(field, props) => (
<>
<label class="form-control input-bordered flex w-full items-center gap-2">
<select
required
class="select select-bordered w-full"
{...props}
>
<option>en</option>
<For each={keymaps}>
{(keymap) => (
<option value={keymap}>
{keymap}
</option>
)}
</For>
</select>
<div class="label">
{isFetchingKeymaps && (
<span class="label-text
-alt">
<span class="loading loading-bars"></span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</>
)}
</Field>
<Field name="sshKeys" validate={[]} type="string[]">
{(field, props) => (
<>
<label class="input input-bordered flex items-center gap-2">
<span class="material-icons">key</span>
<input
type="text"
class="grow"
placeholder="Select SSH Key"
value={field.value ? field.value.join(", ") : ""}
readOnly
onClick={() => selectSshPubkey()}
required
{...props}
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)}
</Field>
<button class="btn btn-error" type="submit" disabled={isFlashing()}>
{isFlashing()
? <span class="loading loading-spinner"></span>
: <span class="material-icons">bolt</span>}
{isFlashing() ? "Flashing..." : "Flash Installer"}
</button> </button>
</Form> </Form>
</div> </div>

View File

@@ -1,9 +1,9 @@
import { import {
For, type Component,
Show,
createEffect, createEffect,
createSignal, createSignal,
type Component, For,
Show,
} from "solid-js"; } from "solid-js";
import { route } from "@/src/App"; import { route } from "@/src/App";
import { OperationResponse, pyApi } from "@/src/api"; import { OperationResponse, pyApi } from "@/src/api";

View File

@@ -1,14 +1,14 @@
import { import {
type Component,
createEffect,
createSignal,
For, For,
Match, Match,
Show, Show,
Switch, Switch,
createEffect,
createSignal,
type Component,
} from "solid-js"; } from "solid-js";
import { activeURI, route, setActiveURI, setRoute } from "@/src/App"; 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 toast from "solid-toast";
import { MachineListItem } from "@/src/components/MachineListItem"; import { MachineListItem } from "@/src/components/MachineListItem";
@@ -91,7 +91,8 @@ export const MachineListView: Component = () => {
<span class="material-icons ">add</span> <span class="material-icons ">add</span>
</button> </button>
</div> </div>
{/* <Show when={services()}> {
/* <Show when={services()}>
{(services) => ( {(services) => (
<For each={Object.values(services())}> <For each={Object.values(services())}>
{(service) => ( {(service) => (
@@ -137,7 +138,8 @@ export const MachineListView: Component = () => {
)} )}
</For> </For>
)} )}
</Show> */} </Show> */
}
<Switch> <Switch>
<Match when={loading()}> <Match when={loading()}>
{/* Loading skeleton */} {/* Loading skeleton */}

View File

@@ -1,16 +1,16 @@
import { callApi } from "@/src/api"; import { callApi } from "@/src/api";
import { import {
SubmitHandler,
createForm, createForm,
required, required,
setValue, setValue,
SubmitHandler,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import { import {
activeURI, activeURI,
setClanList,
setActiveURI,
setRoute,
clanList, clanList,
setActiveURI,
setClanList,
setRoute,
} from "@/src/App"; } from "@/src/App";
import { import {
createEffect, createEffect,
@@ -140,7 +140,7 @@ const ClanDetails = (props: ClanDetailsProps) => {
s.filter((v, idx) => { s.filter((v, idx) => {
if (v == clan_dir) { if (v == clan_dir) {
setActiveURI( setActiveURI(
clanList()[idx - 1] || clanList()[idx + 1] || null clanList()[idx - 1] || clanList()[idx + 1] || null,
); );
return false; return false;
} }

View File

@@ -1,4 +1,4 @@
import { describe, it, expectTypeOf } from "vitest"; import { describe, expectTypeOf, it } from "vitest";
import { OperationNames, pyApi } from "@/src/api"; import { OperationNames, pyApi } from "@/src/api";
@@ -44,13 +44,13 @@ describe.concurrent("API types work properly", () => {
.parameter(0) .parameter(0)
.toMatchTypeOf< .toMatchTypeOf<
| { | {
status: "success"; status: "success";
data: { data: {
machine_name: string; machine_name: string;
machine_icon?: string | null; machine_icon?: string | null;
machine_description?: string | null; machine_description?: string | null;
}; };
} }
| { status: "error"; errors: any } | { status: "error"; errors: any }
>(); >();
}); });

View File

@@ -14,6 +14,7 @@
"allowJs": true, "allowJs": true,
"isolatedModules": true, "isolatedModules": true,
"paths": { "paths": {
"@/*": ["./*"]} "@/*": ["./*"]
}, }
}
} }

View File

@@ -20,8 +20,7 @@ export function isValidHostname(value: string | null | undefined) {
const isValid = labels.every(function (label) { const isValid = labels.every(function (label) {
const validLabelChars = /^([a-zA-Z0-9-]+)$/g; const validLabelChars = /^([a-zA-Z0-9-]+)$/g;
const validLabel = const validLabel = validLabelChars.test(label) &&
validLabelChars.test(label) &&
label.length < 64 && label.length < 64 &&
!label.startsWith("-") && !label.startsWith("-") &&
!label.endsWith("-"); !label.endsWith("-");