clan-app: Finish flash view. clan-cli: Flash cli now verifies if language and keymap are valid.
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
13
pkgs/clan-cli/clan_cli/api/cli.py
Executable file
13
pkgs/clan-cli/clan_cli/api/cli.py
Executable 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))
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
|
||||
4
pkgs/webview-ui/.vscode/settings.json
vendored
4
pkgs/webview-ui/.vscode/settings.json
vendored
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"tailwindCSS.experimental.classRegex": [["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
|
||||
],
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
|
||||
@@ -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.<br>
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
Runs the app in the development mode.<br> Open
|
||||
[http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br>
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `dist` folder.<br>
|
||||
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||
Builds the app for production to the `dist` folder.<br> 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.<br>
|
||||
Your app is ready to be deployed!
|
||||
The build is minified and the filenames include the hashes.<br> 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.)
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -58,11 +58,11 @@ const registry: ObserverRegistry = operationNames.reduce(
|
||||
...acc,
|
||||
[opName]: {},
|
||||
}),
|
||||
{} as ObserverRegistry
|
||||
{} as ObserverRegistry,
|
||||
);
|
||||
|
||||
function createFunctions<K extends OperationNames>(
|
||||
operationName: K
|
||||
operationName: K,
|
||||
): {
|
||||
dispatch: (args: OperationArgs<K>) => 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");
|
||||
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 = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>
|
||||
args: OperationArgs<K>,
|
||||
) => {
|
||||
return new Promise<OperationResponse<K>>((resolve, reject) => {
|
||||
const id = nanoid();
|
||||
@@ -134,9 +134,7 @@ export const callApi = <K extends OperationNames>(
|
||||
});
|
||||
};
|
||||
|
||||
const deserialize =
|
||||
<T>(fn: (response: T) => void) =>
|
||||
(str: string) => {
|
||||
const deserialize = <T>(fn: (response: T) => void) => (str: string) => {
|
||||
try {
|
||||
const r = JSON.parse(str) as T;
|
||||
console.log("Received: ", r);
|
||||
|
||||
@@ -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) => {
|
||||
<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">
|
||||
<span>System:</span>
|
||||
{hwInfo()[name]?.system ? (
|
||||
<span class="text-primary">{hwInfo()[name]?.system}</span>
|
||||
) : (
|
||||
<span class="text-warning">Not set</span>
|
||||
)}
|
||||
{hwInfo()[name]?.system
|
||||
? <span class="text-primary">{hwInfo()[name]?.system}</span>
|
||||
: <span class="text-warning">Not set</span>}
|
||||
</div>
|
||||
|
||||
<div class="badge badge-ghost flex flex-row gap-1 py-4 align-middle">
|
||||
<span>Target Host:</span>
|
||||
{deploymentInfo()[name] ? (
|
||||
<span class="text-primary">{deploymentInfo()[name]}</span>
|
||||
) : (
|
||||
<span class="text-warning">Not set</span>
|
||||
)}
|
||||
{/* <Show
|
||||
{deploymentInfo()[name]
|
||||
? <span class="text-primary">{deploymentInfo()[name]}</span>
|
||||
: <span class="text-warning">Not set</span>}
|
||||
{
|
||||
/* <Show
|
||||
when={deploymentInfo()[name]}
|
||||
fallback={
|
||||
<Switch fallback={<div class="skeleton h-8 w-full"></div>}>
|
||||
@@ -119,7 +116,8 @@ export const MachineListItem = (props: MachineListItemProps) => {
|
||||
}
|
||||
>
|
||||
{(i) => + i()}
|
||||
</Show> */}
|
||||
</Show> */
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{/* Show only the first error at the bottom */}
|
||||
|
||||
@@ -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<ComputePositionReturn, "x" | "y"> {
|
||||
@@ -30,7 +30,7 @@ export interface UseFloatingResult extends UseFloatingState {
|
||||
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
reference: () => R | undefined | null,
|
||||
floating: () => F | undefined | null,
|
||||
options?: UseFloatingOptions<R, F>
|
||||
options?: UseFloatingOptions<R, F>,
|
||||
): UseFloatingResult {
|
||||
const placement = () => options?.placement ?? "bottom";
|
||||
const strategy = () => options?.strategy ?? "absolute";
|
||||
@@ -77,7 +77,7 @@ export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
const cleanup = options.whileElementsMounted(
|
||||
currentReference,
|
||||
currentFloating,
|
||||
update
|
||||
update,
|
||||
);
|
||||
|
||||
if (cleanup) {
|
||||
|
||||
@@ -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(
|
||||
</QueryClientProvider>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
root!
|
||||
root!,
|
||||
);
|
||||
|
||||
@@ -32,7 +32,8 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
for="toplevel-drawer"
|
||||
aria-label="close sidebar"
|
||||
class="drawer-overlay"
|
||||
></label>
|
||||
>
|
||||
</label>
|
||||
<Sidebar route={route} setRoute={setRoute} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,9 @@ export const BlockDevicesView: Component = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex max-w-screen-lg flex-col gap-4">
|
||||
{isFetching ? (
|
||||
<span class="loading loading-bars"></span>
|
||||
) : (
|
||||
{isFetching
|
||||
? <span class="loading loading-bars"></span>
|
||||
: (
|
||||
<Show when={devices}>
|
||||
{(devices) => (
|
||||
<For each={devices().blockdevices}>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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<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 = {
|
||||
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<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 {
|
||||
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 = () => {
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="machine.flake"
|
||||
//placeholder="machine.flake"
|
||||
value="git+https://git.clan.lol/clan/clan-core"
|
||||
required
|
||||
{...props}
|
||||
/>
|
||||
@@ -86,7 +157,7 @@ export const Flash = () => {
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="machine.name"
|
||||
name="machine.devicePath"
|
||||
validate={[required("This field is required")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
@@ -96,7 +167,7 @@ export const Flash = () => {
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="machine.name"
|
||||
value="flash-installer"
|
||||
required
|
||||
{...props}
|
||||
/>
|
||||
@@ -120,13 +191,11 @@ export const Flash = () => {
|
||||
class="select select-bordered w-full"
|
||||
{...props}
|
||||
>
|
||||
{/* <span class="material-icons">devices</span> */}
|
||||
<option disabled>Select a disk</option>
|
||||
|
||||
<option value="" disabled>Select a disk</option>
|
||||
<For each={devices?.blockdevices}>
|
||||
{(device) => (
|
||||
<option value={device.name}>
|
||||
{device.name} / {device.size} bytes
|
||||
<option value={device.path}>
|
||||
{device.path} -- {device.size} bytes
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
@@ -147,8 +216,106 @@ export const Flash = () => {
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<button class="btn btn-error" type="submit">
|
||||
<span class="material-icons">bolt</span>Flash Installer
|
||||
<Field name="language" 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_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>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = () => {
|
||||
<span class="material-icons ">add</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* <Show when={services()}>
|
||||
{
|
||||
/* <Show when={services()}>
|
||||
{(services) => (
|
||||
<For each={Object.values(services())}>
|
||||
{(service) => (
|
||||
@@ -137,7 +138,8 @@ export const MachineListView: Component = () => {
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</Show> */}
|
||||
</Show> */
|
||||
}
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
{/* Loading skeleton */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expectTypeOf } from "vitest";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
|
||||
import { OperationNames, pyApi } from "@/src/api";
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"allowJs": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]}
|
||||
},
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("-");
|
||||
|
||||
Reference in New Issue
Block a user