Merge pull request 'clan-app: Finish flash view. clan-cli: Flash cli now verifies if language and keymap are valid. formatter.nix: Add deno as formatter' (#1839) from Qubasa/clan-core:Qubasa-Qubasa-main into main

This commit is contained in:
clan-bot
2024-08-02 17:02:32 +00:00
38 changed files with 664 additions and 290 deletions

View File

@@ -1,6 +1,6 @@
name: deploy
on:
push:
push:
branches:
- main
jobs:
@@ -10,4 +10,4 @@ jobs:
- uses: actions/checkout@v3
- run: nix run .#deploy-docs
env:
SSH_HOMEPAGE_KEY: ${{ secrets.SSH_HOMEPAGE_KEY }}
SSH_HOMEPAGE_KEY: ${{ secrets.SSH_HOMEPAGE_KEY }}

View File

@@ -1,4 +1,4 @@
{
"publickey": "age15x8u838dwqflr3t6csf4tlghxm4tx77y379ncqxav7y2n8qp7yzqgrwt00",
"type": "age"
}
}

View File

@@ -1,4 +1,4 @@
{
"publickey": "age15x8u838dwqflr3t6csf4tlghxm4tx77y379ncqxav7y2n8qp7yzqgrwt00",
"type": "age"
}
}

View File

@@ -1,12 +1,19 @@
{% extends "base.html" %}
{% block extrahead %}
<meta property="og:title" content="Clan - Documentation, Blog & Getting Started Guide" />
<meta property="og:description" content="Documentation for Clan. The peer-to-peer machine deployment framework." />
<meta property="og:image" content="https://clan.lol/static/dark-favicon/128x128.png" />
{% extends "base.html" %} {% block extrahead %}
<meta
property="og:title"
content="Clan - Documentation, Blog & Getting Started Guide"
/>
<meta
property="og:description"
content="Documentation for Clan. The peer-to-peer machine deployment framework."
/>
<meta
property="og:image"
content="https://clan.lol/static/dark-favicon/128x128.png"
/>
<meta property="og:url" content="https://docs.clan.lol" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Clan" />
<meta property="og:locale" content="en_US" />
{% endblock %}
{% endblock %}

View File

@@ -1,13 +1,13 @@
@font-face {
font-family: "Roboto";
src: url(./Roboto-Regular.ttf) format('truetype');
font-family: "Roboto";
src: url(./Roboto-Regular.ttf) format("truetype");
}
@font-face {
font-family: "Fira Code";
src: url(./FiraCode-VF.ttf) format('truetype');
font-family: "Fira Code";
src: url(./FiraCode-VF.ttf) format("truetype");
}
:root {
--md-text-font: "Roboto";
--md-code-font: "Fira Code";
}
--md-text-font: "Roboto";
--md-code-font: "Fira Code";
}

View File

@@ -11,6 +11,39 @@
treefmt.programs.nixfmt.enable = true;
treefmt.programs.nixfmt.package = pkgs.nixfmt-rfc-style;
treefmt.programs.deadnix.enable = true;
treefmt.settings.global.excludes = [
"*.png"
"*.jpeg"
"*.gitignore"
".vscode/*"
"*.toml"
"*.clan-flake"
"*.code-workspace"
"*.pub"
"*.typed"
"*.age"
"*.list"
"*.desktop"
];
treefmt.programs.prettier = {
enable = true;
includes = [
"*.cjs"
"*.css"
"*.html"
"*.js"
"*.json5"
"*.jsx"
"*.mdx"
"*.mjs"
"*.scss"
"*.ts"
"*.tsx"
"*.vue"
"*.yaml"
"*.yml"
];
};
treefmt.programs.mypy.directories =
{

View File

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

View File

@@ -1,66 +1,63 @@
/* Insert custom styles here */
navigation-view {
padding: 5px;
/* padding-left: 5px;
padding: 5px;
/* padding-left: 5px;
padding-right: 5px;
padding-bottom: 5px; */
}
avatar {
margin: 2px;
margin: 2px;
}
.trust {
padding-top: 25px;
padding-bottom: 25px;
padding-top: 25px;
padding-bottom: 25px;
}
.join-list {
margin-top: 1px;
margin-left: 2px;
margin-right: 2px;
margin-top: 1px;
margin-left: 2px;
margin-right: 2px;
}
.progress-bar {
margin-right: 25px;
min-width: 200px;
margin-right: 25px;
min-width: 200px;
}
.group-list {
background-color: inherit;
background-color: inherit;
}
.group-list > .activatable:hover {
background-color: unset;
background-color: unset;
}
.group-list > row {
margin-top: 12px;
border-bottom: unset;
margin-top: 12px;
border-bottom: unset;
}
.vm-list {
margin-top: 25px;
margin-bottom: 25px;
margin-top: 25px;
margin-bottom: 25px;
}
.no-shadow {
box-shadow: none;
box-shadow: none;
}
.search-entry {
margin-bottom: 12px;
margin-bottom: 12px;
}
searchbar {
margin-bottom: 25px;
margin-bottom: 25px;
}
.log-view {
margin-top: 12px;
font-family: monospace;
padding: 8px;
margin-top: 12px;
font-family: monospace;
padding: 8px;
}

View File

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

View File

@@ -1,26 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Clan Webui",
"type": "python",
"request": "launch",
"module": "clan_cli.webui",
"justMyCode": false,
"args": [ "--reload", "--no-open", "--log-level", "debug" ],
},
{
"name": "Clan Cli VMs",
"type": "python",
"request": "launch",
"module": "clan_cli",
"justMyCode": false,
"args": [ "vms" ],
}
]
}
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Clan Webui",
"type": "python",
"request": "launch",
"module": "clan_cli.webui",
"justMyCode": false,
"args": ["--reload", "--no-open", "--log-level", "debug"]
},
{
"name": "Clan Cli VMs",
"type": "python",
"request": "launch",
"module": "clan_cli",
"justMyCode": false,
"args": ["vms"]
}
]
}

View File

@@ -1,22 +1,22 @@
{
"python.testing.pytestArgs": [
// Coverage is not supported by vscode:
// https://github.com/Microsoft/vscode-python/issues/693
// Note that this will make pytest fail if pytest-cov is not installed,
// if that's the case, then this option needs to be be removed (overrides
// can be set at a workspace level, it's up to you to decide what's the
// best approach). You might also prefer to only set this option
// per-workspace (wherever coverage is used).
"--no-cov",
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"search.exclude": {
"**/.direnv": true
},
"python.linting.mypyPath": "mypy",
"python.linting.mypyEnabled": true,
"python.linting.enabled": true,
"python.defaultInterpreterPath": "python"
}
"python.testing.pytestArgs": [
// Coverage is not supported by vscode:
// https://github.com/Microsoft/vscode-python/issues/693
// Note that this will make pytest fail if pytest-cov is not installed,
// if that's the case, then this option needs to be be removed (overrides
// can be set at a workspace level, it's up to you to decide what's the
// best approach). You might also prefer to only set this option
// per-workspace (wherever coverage is used).
"--no-cov",
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"search.exclude": {
"**/.direnv": true
},
"python.linting.mypyPath": "mypy",
"python.linting.mypyEnabled": true,
"python.linting.enabled": true,
"python.defaultInterpreterPath": "python"
}

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

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

View File

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

View File

@@ -1,23 +1,23 @@
secret-key: ENC[AES256_GCM,data:gjX4OmCUdd3TlA4p,iv:3yZVpyd6FqkITQY0nU2M1iubmzvkR6PfkK2m/s6nQh8=,tag:Abgp9xkiFFylZIyAlap6Ew==,type:str]
nested:
secret-key: ENC[AES256_GCM,data:iUMgDhhIjwvd7wL4,iv:jiJIrh12dSu/sXX+z9ITVoEMNDMjwIlFBnyv40oN4LE=,tag:G9VmAa66Km1sc7JEhW5AvA==,type:str]
secret-key: ENC[AES256_GCM,data:iUMgDhhIjwvd7wL4,iv:jiJIrh12dSu/sXX+z9ITVoEMNDMjwIlFBnyv40oN4LE=,tag:G9VmAa66Km1sc7JEhW5AvA==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age14tva0txcrl0zes05x7gkx56qd6wd9q3nwecjac74xxzz4l47r44sv3fz62
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0eWdRVjlydXlXOVZFQ3lO
bzU1eG9Iam5Ka29Sdlo0cHJ4b1R6bjdNSzBjCkgwRndCbWZQWHlDU0x1cWRmaGVt
N29lbjR6UjN0L2RhaXEzSG9zQmRsZGsKLS0tIEdsdWgxSmZwU3BWUDVxVWRSSC9M
eVZ6bjgwZnR2TTM5MkRYZWNFSFplQWsKmSzv12/dftL9jx2y35UZUGVK6xWdatE8
BGJiCvMlp0BQNrh2s/+YaEaBa48w8LL79U/XJnEZ+ZUwxmlbSTn6Hg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2023-08-08T14:27:20Z"
mac: ENC[AES256_GCM,data:iRWWX+L5Q5nKn3fBCLaWoz/mvqGnNnRd93gJmYXDZbRjFoHa9IFJZst5QDIDa1ZRYUe6G0/+lV5SBi+vwRm1pHysJ3c0ZWYjBP+e1jw3jLXxLV5gACsDC8by+6rFUCho0Xgu+Nqu2ehhNenjQQnCvDH5ivWbW70KFT5ynNgR9Tw=,iv:RYnnbLMC/hNfMwWPreMq9uvY0khajwQTZENO/P34ckY=,tag:Xi1PS5vM1c+sRkroHkPn1Q==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.7.3
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age14tva0txcrl0zes05x7gkx56qd6wd9q3nwecjac74xxzz4l47r44sv3fz62
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0eWdRVjlydXlXOVZFQ3lO
bzU1eG9Iam5Ka29Sdlo0cHJ4b1R6bjdNSzBjCkgwRndCbWZQWHlDU0x1cWRmaGVt
N29lbjR6UjN0L2RhaXEzSG9zQmRsZGsKLS0tIEdsdWgxSmZwU3BWUDVxVWRSSC9M
eVZ6bjgwZnR2TTM5MkRYZWNFSFplQWsKmSzv12/dftL9jx2y35UZUGVK6xWdatE8
BGJiCvMlp0BQNrh2s/+YaEaBa48w8LL79U/XJnEZ+ZUwxmlbSTn6Hg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2023-08-08T14:27:20Z"
mac: ENC[AES256_GCM,data:iRWWX+L5Q5nKn3fBCLaWoz/mvqGnNnRd93gJmYXDZbRjFoHa9IFJZst5QDIDa1ZRYUe6G0/+lV5SBi+vwRm1pHysJ3c0ZWYjBP+e1jw3jLXxLV5gACsDC8by+6rFUCho0Xgu+Nqu2ehhNenjQQnCvDH5ivWbW70KFT5ynNgR9Tw=,iv:RYnnbLMC/hNfMwWPreMq9uvY0khajwQTZENO/P34ckY=,tag:Xi1PS5vM1c+sRkroHkPn1Q==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.7.3

View File

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

View File

@@ -1,66 +1,63 @@
/* Insert custom styles here */
navigation-view {
padding: 5px;
/* padding-left: 5px;
padding: 5px;
/* padding-left: 5px;
padding-right: 5px;
padding-bottom: 5px; */
}
avatar {
margin: 2px;
margin: 2px;
}
.trust {
padding-top: 25px;
padding-bottom: 25px;
padding-top: 25px;
padding-bottom: 25px;
}
.join-list {
margin-top: 1px;
margin-left: 2px;
margin-right: 2px;
margin-top: 1px;
margin-left: 2px;
margin-right: 2px;
}
.progress-bar {
margin-right: 25px;
min-width: 200px;
margin-right: 25px;
min-width: 200px;
}
.group-list {
background-color: inherit;
background-color: inherit;
}
.group-list > .activatable:hover {
background-color: unset;
background-color: unset;
}
.group-list > row {
margin-top: 12px;
border-bottom: unset;
margin-top: 12px;
border-bottom: unset;
}
.vm-list {
margin-top: 25px;
margin-bottom: 25px;
margin-top: 25px;
margin-bottom: 25px;
}
.no-shadow {
box-shadow: none;
box-shadow: none;
}
.search-entry {
margin-bottom: 12px;
margin-bottom: 12px;
}
searchbar {
margin-bottom: 25px;
margin-bottom: 25px;
}
.log-view {
margin-top: 12px;
font-family: monospace;
padding: 8px;
margin-top: 12px;
font-family: monospace;
padding: 8px;
}

View File

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

View File

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

View File

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

View File

@@ -28,5 +28,5 @@ export default tseslint.config(
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
},
}
},
);

View File

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

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>Solid App</title>

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
@import 'material-icons/iconfont/filled.css';
@import "material-icons/iconfont/filled.css";
/* List of icons: https://marella.me/material-icons/demo/ */
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
overflow-x: hidden;
overflow-y: scroll;
}
overflow-x: hidden;
overflow-y: scroll;
}

View File

@@ -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!,
);

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

View File

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

View File

@@ -1,25 +1,23 @@
import { route } from "@/src/App";
import { OperationArgs, OperationResponse, callApi, pyApi } from "@/src/api";
import { SubmitHandler, createForm, required } from "@modular-forms/solid";
import { callApi, OperationResponse } from "@/src/api";
import {
createForm,
required,
FieldValues,
setValue,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query";
import { For, createSignal } from "solid-js";
import { effect } from "solid-js/web";
import { createEffect, createSignal, For } from "solid-js";
// 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 = {
interface FlashFormValues extends FieldValues {
machine: {
name: string;
devicePath: string;
flake: string;
};
disk: string;
};
language: string;
keymap: string;
sshKeys: string[];
}
type BlockDevices = Extract<
OperationResponse<"show_block_devices">,
@@ -28,32 +26,95 @@ type BlockDevices = Extract<
export const Flash = () => {
const [formStore, { Form, Field }] = createForm<FlashFormValues>({});
const [sshKeys, setSshKeys] = createSignal<string[]>([]);
const [isFlashing, setIsFlashing] = createSignal(false);
const {
data: devices,
refetch: loadDevices,
isFetching,
} = createQuery(() => ({
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, isFetching } = createQuery(() => ({
queryKey: ["block_devices"],
queryFn: async () => {
const result = await callApi("show_block_devices", {});
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 +131,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 +148,7 @@ export const Flash = () => {
)}
</Field>
<Field
name="machine.name"
name="machine.devicePath"
validate={[required("This field is required")]}
>
{(field, props) => (
@@ -96,7 +158,7 @@ export const Flash = () => {
<input
type="text"
class="grow"
placeholder="machine.name"
value="flash-installer"
required
{...props}
/>
@@ -120,13 +182,13 @@ 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 +209,99 @@ 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>

View File

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

View File

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

View File

@@ -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,
@@ -33,17 +33,17 @@ export const registerClan = async () => {
});
console.log({ loc }, loc.status);
if (loc.status === "success" && loc.data) {
// @ts-expect-error: data is a string
const data = loc.data[0];
setClanList((s) => {
const res = new Set([...s, loc.data]);
const res = new Set([...s, data]);
return Array.from(res);
});
setActiveURI(loc.data);
setActiveURI(data);
setRoute((r) => {
if (r === "welcome") return "machines";
return r;
});
return loc.data;
return data;
}
} catch (e) {
//
@@ -140,12 +140,12 @@ 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;
}
return true;
})
}),
);
}}
>

View File

@@ -1,4 +1,4 @@
import { describe, it, expectTypeOf } from "vitest";
import { describe, expectTypeOf, it } from "vitest";
import { OperationNames, pyApi } from "@/src/api";

View File

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