Merge pull request 'clan-app: Init machine based API logging' (#4133) from Qubasa/clan-core:improve_ui_logs into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4133
This commit is contained in:
Luis Hebendanz
2025-07-02 11:30:22 +00:00
32 changed files with 419 additions and 538 deletions

View File

@@ -1,7 +1,7 @@
# shellcheck shell=bash
source_up
watch_file flake-module.nix shell.nix webview-ui/flake-module.nix
watch_file .local.env flake-module.nix shell.nix webview-ui/flake-module.nix
# Because we depend on nixpkgs sources, uploading to builders takes a long time
use flake .#clan-app --builders ''

View File

@@ -103,6 +103,18 @@ GTK_DEBUG=interactive ./bin/clan-app --debug
Appending `--debug` flag enables debug logging printed into the console.
Debugging crashes in the `webview` library can be done by executing:
```bash
$ ./pygdb.sh ./bin/clan-app --content-uri http://localhost:3000/ --debug
```
I recommend creating the file `.local.env` with the content:
```bash
export WEBVIEW_LIB_DIR=$HOME/Projects/webview/build/core
```
where `WEBVIEW_LIB_DIR` points to a local checkout of the webview lib source, that has been build by hand. The `.local.env` file will be automatically sourced if it exists and will be ignored by git.
### Profiling
To activate profiling you can run
@@ -111,51 +123,3 @@ To activate profiling you can run
CLAN_CLI_PERF=1 ./bin/clan-app
```
### Library Components
> Note:
>
> we recognized bugs when starting some cli-commands through the integrated vs-code terminal.
> If encountering issues make sure to run commands in a regular os-shell.
lib-Adw has a demo application showing all widgets. You can run it by executing
```bash
adwaita-1-demo
```
GTK4 has a demo application showing all widgets. You can run it by executing
```bash
gtk4-widget-factory
```
To find available icons execute
```bash
gtk4-icon-browser
```
### Links
Here are some important documentation links related to the Clan App:
- [GTK4 PyGobject Reference](http://lazka.github.io/pgi-docs/index.html#Gtk-4.0): This link provides the PyGObject reference documentation for GTK4, the toolkit used for building the user interface of the clan app. It includes information about GTK4 widgets, signals, and other features.
- [Adw Widget Gallery](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/widget-gallery.html): This link showcases a widget gallery for Adw, allowing you to see the available widgets and their visual appearance. It can be helpful for designing the user interface of the clan app.
- [GNOME Human Interface Guidelines](https://developer.gnome.org/hig/): This link provides the GNOME Human Interface Guidelines, which offer design and usability recommendations for creating GNOME applications. It covers topics such as layout, navigation, and interaction patterns.
## Error handling
> Error dialogs should be avoided where possible, since they are disruptive.
>
> For simple non-critical errors, toasts can be a good alternative.
[direnv]: https://direnv.net/
[process-compose]: https://f1bonacc1.github.io/process-compose/
[vite]: https://vite.dev/
[webview]: https://github.com/webview/webview
[Storybook]: https://storybook.js.org/
[webkit]: https://webkit.org/

View File

@@ -8,7 +8,7 @@ from dataclasses import dataclass
from pathlib import Path
import clan_lib.machines.actions # noqa: F401
from clan_lib.api import API, ErrorDataClass, SuccessDataClass
from clan_lib.api import API, tasks
# TODO: We have to manually import python files to make the API.register be triggered.
# We NEED to fix this, as this is super unintuitive and error-prone.
@@ -46,45 +46,16 @@ def app_run(app_opts: ClanAppOptions) -> int:
webview = Webview(debug=app_opts.debug)
webview.title = "Clan App"
# This seems to call the gtk api correctly but and gtk also seems to our icon, but somehow the icon is not loaded.
webview.icon = "clan-white"
# Init LogManager global in log_manager_api module
log_manager_api.LOG_MANAGER_INSTANCE = LogManager(
base_dir=user_data_dir() / "clan-app" / "logs"
)
def cancel_task(
task_id: str, *, op_key: str
) -> SuccessDataClass[None] | ErrorDataClass:
"""Cancel a task by its op_key."""
log.debug(f"Cancelling task with op_key: {task_id}")
future = webview.threads.get(task_id)
if future:
future.stop_event.set()
log.debug(f"Task {task_id} cancelled.")
else:
log.warning(f"Task {task_id} not found.")
return SuccessDataClass(
op_key=op_key,
data=None,
status="success",
)
# Init BAKEND_THREADS in tasks module
tasks.BAKEND_THREADS = webview.threads
def list_tasks(
*,
op_key: str,
) -> SuccessDataClass[list[str]] | ErrorDataClass:
"""List all tasks."""
log.debug("Listing all tasks.")
tasks = list(webview.threads.keys())
return SuccessDataClass(
op_key=op_key,
data=tasks,
status="success",
)
API.overwrite_fn(list_tasks)
API.overwrite_fn(open_file)
API.overwrite_fn(cancel_task)
webview.bind_jsonschema_api(API, log_manager=log_manager_api.LOG_MANAGER_INSTANCE)
webview.size = Size(1280, 1024, SizeHint.NONE)
webview.navigate(content_uri)

View File

@@ -88,9 +88,6 @@ class _WebviewLibrary:
self.webview_set_title = self.lib.webview_set_title
self.webview_set_title.argtypes = [c_void_p, c_char_p]
self.webview_set_icon = self.lib.webview_set_icon
self.webview_set_icon.argtypes = [c_void_p, c_char_p]
self.webview_set_size = self.lib.webview_set_size
self.webview_set_size.argtypes = [c_void_p, c_int, c_int, c_int]
@@ -112,6 +109,8 @@ class _WebviewLibrary:
self.webview_return = self.lib.webview_return
self.webview_return.argtypes = [c_void_p, c_char_p, c_int, c_char_p]
self.binding_callback_t = CFUNCTYPE(None, c_char_p, c_char_p, c_void_p)
self.CFUNCTYPE = CFUNCTYPE

View File

@@ -1,11 +1,9 @@
import ctypes
import functools
import io
import json
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
@@ -16,6 +14,7 @@ from clan_lib.api import (
dataclass_to_dict,
from_dict,
)
from clan_lib.api.tasks import WebThread
from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx
from clan_lib.custom_logger import setup_logging
from clan_lib.log_manager import LogManager
@@ -44,12 +43,6 @@ class Size:
self.hint = hint
@dataclass
class WebThread:
thread: threading.Thread
stop_event: threading.Event
class Webview:
def __init__(
self, debug: bool = False, size: Size | None = None, window: int | None = None
@@ -73,21 +66,26 @@ class Webview:
) -> None:
op_key = op_key_bytes.decode()
args = json.loads(request_data.decode())
log.debug(f"Calling {method_name}({args[0]})")
log.debug(f"Calling {method_name}({args})")
header: dict[str, Any]
try:
# Initialize dataclasses from the payload
reconciled_arguments = {}
for k, v in args[0].items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = api.get_method_argtype(method_name, k)
if len(args) > 1:
header = args[1]
for k, v in args[0].items():
# Some functions expect to be called with dataclass instances
# But the js api returns dictionaries.
# Introspect the function and create the expected dataclass from dict dynamically
# Depending on the introspected argument_type
arg_class = api.get_method_argtype(method_name, k)
# TODO: rename from_dict into something like construct_checked_value
# from_dict really takes Anything and returns an instance of the type/class
reconciled_arguments[k] = from_dict(arg_class, v)
# TODO: rename from_dict into something like construct_checked_value
# from_dict really takes Anything and returns an instance of the type/class
reconciled_arguments[k] = from_dict(arg_class, v)
elif len(args) == 1:
header = args[0]
reconciled_arguments["op_key"] = op_key
except Exception as e:
@@ -112,8 +110,16 @@ class Webview:
def thread_task(stop_event: threading.Event) -> None:
ctx: AsyncContext = get_async_ctx()
ctx.should_cancel = lambda: stop_event.is_set()
# If the API call has set log_group in metadata,
# create the log file under that group.
log_group = header.get("logging", {}).get("group", None)
if log_group is not None:
log.warning(
f"Using log group {log_group} for {method_name} with op_key {op_key}"
)
log_file = log_manager.create_log_file(
wrap_method, op_key=op_key
wrap_method, op_key=op_key, group=log_group
).get_file_path()
with log_file.open("ab") as log_f:
@@ -129,15 +135,15 @@ class Webview:
handler = setup_logging(
log.getEffectiveLevel(), log_file=handler_stream
)
log.info("Starting thread for webview API call")
try:
# Original logic: call the wrapped API method.
result = wrap_method(**reconciled_arguments)
wrapped_result = {"body": dataclass_to_dict(result), "header": {}}
# Serialize the result to JSON.
serialized = json.dumps(
dataclass_to_dict(result), indent=4, ensure_ascii=False
dataclass_to_dict(wrapped_result), indent=4, ensure_ascii=False
)
# This log message will now also be written to log_f
@@ -204,15 +210,6 @@ class Webview:
_webview_lib.webview_set_title(self._handle, _encode_c_string(value))
self._title = value
@property
def icon(self) -> str:
return self._icon
@icon.setter
def icon(self, value: str) -> None:
_webview_lib.webview_set_icon(self._handle, _encode_c_string(value))
self._icon = value
def destroy(self) -> None:
for name in list(self._callbacks.keys()):
self.unbind(name)
@@ -237,9 +234,7 @@ class Webview:
name,
method,
)
c_callback = _webview_lib.CFUNCTYPE(
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p
)(wrapper)
c_callback = _webview_lib.binding_callback_t(wrapper)
if name in self._callbacks:
msg = f"Callback {name} already exists. Skipping binding."
@@ -261,9 +256,7 @@ class Webview:
success = False
self.return_(seq.decode(), 0 if success else 1, json.dumps(result))
c_callback = _webview_lib.CFUNCTYPE(
None, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p
)(wrapper)
c_callback = _webview_lib.binding_callback_t(wrapper)
self._callbacks[name] = c_callback
_webview_lib.webview_bind(
self._handle, _encode_c_string(name), c_callback, None

5
pkgs/clan-app/pygdb.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
PYTHON_DIR=$(dirname "$(which python3)")/..
gdb --quiet -ex "source $PYTHON_DIR/share/gdb/libpython.py" --ex "sharedlib $WEBVIEW_LIB_DIR/libwebview.so" --ex "run" --args python "$@"

View File

@@ -89,9 +89,10 @@ mkShell {
popd
# configure process-compose
if test -f "$GIT_ROOT/pkgs/clan-app/.local.env"; then
source "$GIT_ROOT/pkgs/clan-app/.local.env"
if test -f "$CLAN_CORE_PATH/pkgs/clan-app/.local.env"; then
source "$CLAN_CORE_PATH/pkgs/clan-app/.local.env"
fi
export PC_CONFIG_FILES="$CLAN_CORE_PATH/pkgs/clan-app/process-compose.yaml"
echo -e "${GREEN}To launch a qemu VM for testing, run:\n start-vm <number of VMs>${NC}"

View File

@@ -1,84 +0,0 @@
{
"name": "@clan/ui",
"version": "0.0.1",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "npm run check && npm run test && vite build && npm run convert-html",
"convert-html": "node gtk.webview.js",
"serve": "vite preview",
"check": "tsc --noEmit --skipLibCheck && eslint ./src --fix",
"knip": "knip --fix",
"test": "vitest run --project unit --typecheck",
"storybook": "storybook",
"storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook",
"test-storybook-update-snapshots": "vitest run --project storybook --update",
"test-storybook-static": "npm run storybook-build && concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'npx http-server storybook-static --port 6006 --silent' 'npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
},
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.3.0",
"@kachurun/storybook-solid": "^9.0.11",
"@kachurun/storybook-solid-vite": "^9.0.11",
"@storybook/addon-a11y": "^9.0.8",
"@storybook/addon-docs": "^9.0.8",
"@storybook/addon-links": "^9.0.8",
"@storybook/addon-onboarding": "^9.0.8",
"@storybook/addon-vitest": "^9.0.8",
"@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.15.19",
"@vitest/browser": "^3.2.3",
"autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"concurrently": "^9.1.2",
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"jsdom": "^26.1.0",
"knip": "^5.61.2",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"solid-devtools": "^0.34.0",
"storybook": "^9.0.8",
"tailwindcss": "^4.0.0",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"vite": "^7.0.0",
"vite-plugin-solid": "^2.8.2",
"vite-plugin-solid-svg": "^0.8.1",
"vitest": "^3.2.3"
},
"dependencies": {
"@floating-ui/dom": "^1.6.8",
"@kobalte/core": "^0.13.10",
"@kobalte/tailwindcss": "^0.9.0",
"@modular-forms/solid": "^0.25.1",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.15.3",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0",
"corvu": "^0.7.1",
"nanoid": "^5.0.7",
"solid-js": "^1.9.7",
"solid-toast": "^0.5.0"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",
"@esbuild/darwin-x64": "^0.25.4",
"@esbuild/linux-arm64": "^0.25.4",
"@esbuild/linux-x64": "^0.25.4"
},
"overrides": {
"vite": {
"rollup": "npm:@rollup/wasm-node@^4.34.9"
},
"@rollup/rollup-darwin-x64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-linux-x64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-darwin-arm64": "npm:@rollup/wasm-node@^4.34.9",
"@rollup/rollup-linux-arm64": "npm:@rollup/wasm-node@^4.34.9"
}
}

View File

@@ -0,0 +1 @@
../ui/package.json

View File

@@ -19,7 +19,6 @@ import {
} from "@/src/components/inputBase";
import { FieldLayout } from "./layout";
import Icon from "@/src/components/icon";
import { useContext } from "corvu/dialog";
interface Option {
value: string;
@@ -51,9 +50,6 @@ interface SelectInputpProps {
}
export function SelectInput(props: SelectInputpProps) {
const dialogContext = (dialogContextId?: string) =>
useContext(dialogContextId);
const _id = createUniqueId();
const [reference, setReference] = createSignal<HTMLElement>();

View File

@@ -23,37 +23,85 @@ export type SuccessQuery<T extends OperationNames> = Extract<
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
function isMachine(obj: unknown): obj is Machine {
return (
!!obj &&
typeof obj === "object" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (obj as any).name === "string" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (obj as any).flake === "object" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (obj as any).flake.identifier === "string"
);
}
// Machine type with flake for API calls
interface Machine {
name: string;
flake: {
identifier: string;
};
}
interface BackendOpts {
logging?: { group: string | Machine };
}
interface BackendReturnType<K extends OperationNames> {
body: OperationResponse<K>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: Record<string, any>;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
backendOpts?: BackendOpts,
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
// return a rejected promise
return {
promise: Promise.resolve({
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
body: {
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
},
header: {},
}),
op_key: "noop",
};
}
let header: BackendOpts = {};
if (backendOpts != undefined) {
header = { ...backendOpts };
const group = backendOpts?.logging?.group;
if (group != undefined && isMachine(group)) {
header = {
logging: { group: group.flake.identifier + "#" + group.name },
};
}
}
const promise = (
window as unknown as Record<
OperationNames,
(
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
metadata: BackendOpts,
) => Promise<BackendReturnType<OperationNames>>
>
)[method](args) as Promise<OperationResponse<K>>;
)[method](args, header) as Promise<BackendReturnType<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (promise as any)._webviewMessageId as string;
@@ -63,7 +111,7 @@ const _callApi = <K extends OperationNames>(
const handleCancel = async <K extends OperationNames>(
ops_key: string,
orig_task: Promise<OperationResponse<K>>,
orig_task: Promise<BackendReturnType<K>>,
) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
@@ -83,7 +131,7 @@ const handleCancel = async <K extends OperationNames>(
});
const resp = await promise;
if (resp.status === "error") {
if (resp.body.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
@@ -105,10 +153,11 @@ const handleCancel = async <K extends OperationNames>(
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: BackendOpts,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args);
console.log("Calling API", method, args, backendOpts);
const { promise, op_key } = _callApi(method, args);
const { promise, op_key } = _callApi(method, args, backendOpts);
promise.catch((error) => {
toast.custom(
(t) => (
@@ -146,13 +195,14 @@ export const callApi = <K extends OperationNames>(
console.log("Not printing toast because operation was cancelled");
}
if (response.status === "error" && !cancelled) {
const body = response.body;
if (body.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + response.errors[0].message}
message={"Error: " + body.errors[0].message}
/>
),
{
@@ -162,7 +212,8 @@ export const callApi = <K extends OperationNames>(
} else {
toast.remove(toastId);
}
return response;
return body;
});
return { promise: new_promise, op_key: op_key };
};

View File

@@ -61,7 +61,7 @@ export const ApiTester = () => {
return await callApi(
values.endpoint as keyof API,
JSON.parse(values.payload || "{}"),
);
).promise;
},
staleTime: Infinity,
enabled: false,

View File

@@ -27,5 +27,5 @@
}
.button--dark-active:active {
@apply active:border-secondary-900 active:shadow-button-primary-active;
@apply active:border-secondary-900;
}

View File

@@ -7,5 +7,5 @@
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-button-primary-active;
@apply active:bg-secondary-200 active:text-secondary-900;
}

View File

@@ -27,7 +27,7 @@
}
.button--light-active:active {
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900 active:shadow-button-primary-active;
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900;
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);

View File

@@ -17,7 +17,7 @@ const defaultRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 0,
host_key_check: "strict",
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
@@ -32,7 +32,7 @@ const sampleRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 1,
host_key_check: "ask",
verbose_ssh: false,
ssh_options: {
StrictHostKeyChecking: "no",
@@ -238,7 +238,7 @@ const advancedRemoteData: RemoteData = {
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: 2,
host_key_check: "none",
verbose_ssh: true,
ssh_options: {
ConnectTimeout: "10",

View File

@@ -11,13 +11,6 @@ import { Loader } from "@/src/components/v2/Loader/Loader";
import { Button } from "@/src/components/v2/Button/Button";
import Accordion from "@/src/components/accordion";
// Define the HostKeyCheck enum values with proper API mapping
export enum HostKeyCheck {
ASK = 0,
TOFU = 1,
IGNORE = 2,
}
// Export the API types for use in other components
export type { RemoteData, Machine, RemoteDataSource };
@@ -185,40 +178,6 @@ export function RemoteForm(props: RemoteFormProps) {
const [formData, setFormData] = createSignal<RemoteData | null>(null);
const [isSaving, setIsSaving] = createSignal(false);
const hostKeyCheckOptions = [
{ value: "ASK", label: "Ask" },
{ value: "TOFU", label: "TOFU (Trust On First Use)" },
{ value: "IGNORE", label: "Ignore" },
];
// Helper function to convert enum name to numeric value
const getHostKeyCheckValue = (name: string): number => {
switch (name) {
case "ASK":
return HostKeyCheck.ASK;
case "TOFU":
return HostKeyCheck.TOFU;
case "IGNORE":
return HostKeyCheck.IGNORE;
default:
return HostKeyCheck.ASK;
}
};
// Helper function to convert numeric value to enum name
const getHostKeyCheckName = (value: number | undefined): string => {
switch (value) {
case HostKeyCheck.ASK:
return "ASK";
case HostKeyCheck.TOFU:
return "TOFU";
case HostKeyCheck.IGNORE:
return "IGNORE";
default:
return "ASK";
}
};
// Query host data when machine is provided
const hostQuery = useQuery(() => ({
queryKey: [
@@ -241,11 +200,19 @@ export function RemoteForm(props: RemoteFormProps) {
});
}
const result = await callApi("get_host", {
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
}).promise;
const result = await callApi(
"get_host",
{
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
},
{
logging: {
group: { name: props.machine.name, flake: props.machine.flake },
},
},
).promise;
if (result.status === "error")
throw new Error("Failed to fetch host data");
@@ -372,16 +339,13 @@ export function RemoteForm(props: RemoteFormProps) {
<SelectInput
label="Host Key Check"
value={getHostKeyCheckName(formData()?.host_key_check)}
options={hostKeyCheckOptions}
selectProps={{
onInput: (e) =>
updateFormData({
host_key_check: getHostKeyCheckValue(
e.currentTarget.value,
) as 0 | 1 | 2 | 3,
}),
}}
value={formData()?.host_key_check || "ask"}
options={[
{ value: "ask", label: "Ask" },
{ value: "none", label: "None" },
{ value: "strict", label: "Strict" },
{ value: "tofu", label: "Trust on First Use" },
]}
disabled={computedDisabled}
helperText="How to handle host key verification"
/>

View File

@@ -0,0 +1,39 @@
import { JSX, Show } from "solid-js";
interface SimpleModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: JSX.Element;
}
export const SimpleModal = (props: SimpleModalProps) => {
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div class="fixed inset-0 bg-black/50" onClick={props.onClose} />
{/* Modal Content */}
<div class="relative mx-4 w-full max-w-md rounded-lg bg-white shadow-lg">
{/* Header */}
<Show when={props.title}>
<div class="flex items-center justify-between border-b p-4">
<h3 class="text-lg font-semibold">{props.title}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600"
onClick={props.onClose}
>
×
</button>
</div>
</Show>
{/* Body */}
<div>{props.children}</div>
</div>
</div>
</Show>
);
};

View File

@@ -125,7 +125,7 @@ export const InputLabel = (props: InputLabelProps) => {
weight="bold"
class="inline-flex gap-1 align-middle !fg-def-1"
classList={{
[cx("!fg-semantic-1")]: !!props.error,
[cx("!text-red-600")]: !!props.error,
}}
aria-invalid={props.error}
>
@@ -185,7 +185,7 @@ export const InputError = (props: InputErrorProps) => {
// @ts-expect-error: Dependent type is to complex to check how it is coupled to the override for now
size="xxs"
weight="medium"
class={cx("col-span-full px-1 !fg-semantic-4", typoClasses)}
class={cx("col-span-full px-1 !text-red-500", typoClasses)}
{...rest}
>
{props.error}

View File

@@ -47,11 +47,15 @@ export const MachineListItem = (props: MachineListItemProps) => {
);
return;
}
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
const target_host = await callApi(
"get_host",
{
field: "targetHost",
flake: { identifier: active_clan },
name: name,
},
{ logging: { group: { name, flake: { identifier: active_clan } } } },
).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
@@ -79,7 +83,6 @@ export const MachineListItem = (props: MachineListItemProps) => {
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
target_host: target_host.data!.data,
@@ -104,11 +107,17 @@ export const MachineListItem = (props: MachineListItemProps) => {
}
setUpdating(true);
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
const target_host = await callApi(
"get_host",
{
field: "targetHost",
flake: { identifier: active_clan },
name: name,
},
{
logging: { group: { name, flake: { identifier: active_clan } } },
},
).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
@@ -125,11 +134,15 @@ export const MachineListItem = (props: MachineListItemProps) => {
return;
}
const build_host = await callApi("get_host", {
field: "buildHost",
flake: { identifier: active_clan },
name: name,
}).promise;
const build_host = await callApi(
"get_host",
{
field: "buildHost",
flake: { identifier: active_clan },
name: name,
},
{ logging: { group: { name, flake: { identifier: active_clan } } } },
).promise;
if (build_host.status == "error") {
console.error("No target host found for the machine");
@@ -141,16 +154,20 @@ export const MachineListItem = (props: MachineListItemProps) => {
return;
}
await callApi("deploy_machine", {
machine: {
name: name,
flake: {
identifier: active_clan,
await callApi(
"deploy_machine",
{
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
},
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
}).promise;
{ logging: { group: { name, flake: { identifier: active_clan } } } },
).promise;
setUpdating(false);
};

View File

@@ -1,134 +0,0 @@
import Dialog from "corvu/dialog";
import { createSignal, JSX } from "solid-js";
import { Button } from "../Button/Button";
import Icon from "../icon";
import cx from "classnames";
interface ModalProps {
open: boolean | undefined;
handleClose: () => void;
title: string;
children: JSX.Element;
class?: string;
}
export const Modal = (props: ModalProps) => {
const [dragging, setDragging] = createSignal(false);
const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 });
let dialogRef: HTMLDivElement;
const handleMouseDown = (e: MouseEvent) => {
setDragging(true);
const rect = dialogRef.getBoundingClientRect();
setStartOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleMouseMove = (e: MouseEvent) => {
if (dragging()) {
let newTop = e.clientY - startOffset().y;
let newLeft = e.clientX - startOffset().x;
if (newTop < 0) {
newTop = 0;
}
if (newLeft < 0) {
newLeft = 0;
}
dialogRef.style.top = `${newTop}px`;
dialogRef.style.left = `${newLeft}px`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
}
};
const handleMouseUp = () => setDragging(false);
return (
<Dialog open={props.open} trapFocus={true}>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-50 bg-black/50"
onMouseMove={handleMouseMove}
/>
<Dialog.Content
class={cx(
"overflow-hidden absolute left-1/3 top-1/3 z-50 min-w-[560px] rounded-md border border-def-4 focus-visible:outline-none",
props.class,
)}
classList={{
"!cursor-grabbing": dragging(),
[cx("scale-[101%] transition-transform")]: dragging(),
}}
ref={(el) => {
dialogRef = el;
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseDown={(e: MouseEvent) => {
e.stopPropagation(); // Prevent backdrop drag conflict
}}
onClick={(e: MouseEvent) => e.stopPropagation()} // Prevent backdrop click closing
>
<Dialog.Label
as="div"
class="flex w-full justify-center border-b-2 px-4 py-2 align-middle bg-def-3 border-def-4"
onMouseDown={handleMouseDown}
>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<span class="mx-2 select-none whitespace-nowrap">
{props.title}
</span>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<div class="absolute right-1 top-2 pl-1 bg-def-3">
<Button
onMouseDown={(e) => e.stopPropagation()}
tabIndex={-1}
class="size-4"
variant="ghost"
onClick={() => props.handleClose()}
size="s"
startIcon={<Icon icon={"Close"} />}
/>
</div>
</Dialog.Label>
<Dialog.Description
class="flex max-h-[90vh] flex-col overflow-y-hidden bg-def-1"
as="div"
>
{props.children}
</Dialog.Description>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
@import "material-icons/iconfont/filled.css";
/* @import "material-icons/iconfont/filled.css"; */
/* List of icons: https://marella.me/material-icons/demo/ */
/* @import url(./components/Typography/css/typography.css); */

View File

@@ -19,10 +19,12 @@ import { createEffect, createSignal } from "solid-js"; // For, Show might not be
import toast from "solid-toast";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel } from "@/src/components/inputBase";
import { Modal } from "@/src/components/modal";
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
import Accordion from "@/src/components/accordion";
import { SimpleModal } from "@/src/components/SimpleModal";
// Import the new generic component
import {
FileSelectorField,
@@ -192,12 +194,11 @@ export const Flash = () => {
return (
<>
<Header title="Flash installer" />
<Modal
<SimpleModal
open={confirmOpen() || isFlashing()}
handleClose={() => !isFlashing() && setConfirmOpen(false)}
onClose={() => !isFlashing() && setConfirmOpen(false)}
title="Confirm"
>
{/* ... Modal content as before ... */}
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2">
<Typography
@@ -230,7 +231,7 @@ export const Flash = () => {
</Button>
</div>
</div>
</Modal>
</SimpleModal>
<div class="w-full self-stretch p-8">
<Form
onSubmit={handleSubmit}

View File

@@ -125,7 +125,6 @@ export function InstallMachine(props: InstallMachineProps) {
machine: {
name: props.name,
flake: { identifier: curr_uri },
private_key: values.sshKey?.name,
},
},
target_host: targetHostResponse.data.data,

View File

@@ -77,10 +77,18 @@ export function MachineForm(props: MachineFormProps) {
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
}).promise;
const result = await callApi(
"get_generators_closure",
{
base_dir: base_dir,
machine_name: machine_name,
},
{
logging: {
group: { name: machine_name, flake: { identifier: base_dir } },
},
},
).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
@@ -112,13 +120,19 @@ export function MachineForm(props: MachineFormProps) {
return;
}
const target = await callApi("get_host", {
field: "targetHost",
name: machine,
flake: {
identifier: curr_uri,
const target = await callApi(
"get_host",
{
field: "targetHost",
name: machine,
flake: {
identifier: curr_uri,
},
},
}).promise;
{
logging: { group: { name: machine, flake: { identifier: curr_uri } } },
},
).promise;
if (target.status === "error") {
toast.error("Failed to get target host");
@@ -132,18 +146,24 @@ export function MachineForm(props: MachineFormProps) {
const target_host = target.data.data;
setIsUpdating(true);
const r = await callApi("deploy_machine", {
machine: {
name: machine,
flake: {
identifier: curr_uri,
const r = await callApi(
"deploy_machine",
{
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
target_host: {
...target_host,
},
build_host: null,
},
target_host: {
...target_host,
{
logging: { group: { name: machine, flake: { identifier: curr_uri } } },
},
build_host: null,
}).promise.finally(() => {
).promise.finally(() => {
setIsUpdating(false);
});
};

View File

@@ -44,7 +44,7 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
command_prefix: "sudo",
port: 22,
forward_agent: false,
host_key_check: 1, // 0 = ASK
host_key_check: "ask", // 0 = ASK
verbose_ssh: false,
ssh_options: {},
tor_socks: false,

View File

@@ -149,11 +149,19 @@ export const VarsStep = (props: VarsStepProps) => {
const generatorsQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "generators", props.fullClosure],
queryFn: async () => {
const result = await callApi("get_generators_closure", {
base_dir: props.dir,
machine_name: props.machine_id,
full_closure: props.fullClosure,
}).promise;
const result = await callApi(
"get_generators_closure",
{
base_dir: props.dir,
machine_name: props.machine_id,
full_closure: props.fullClosure,
},
{
logging: {
group: { name: props.machine_id, flake: { identifier: props.dir } },
},
},
).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},

View File

@@ -8,6 +8,7 @@ import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
import { makePersisted } from "@solid-primitives/storage";
import { useClanContext } from "@/src/contexts/clan";
import { debug } from "console";
type MachinesModel = Extract<
OperationResponse<"list_machines">,
@@ -38,6 +39,7 @@ export const MachineListView: Component = () => {
},
}).promise;
console.log("response", response);
if (response.status === "error") {
console.error("Failed to fetch data");
} else {

View File

@@ -1,26 +1,19 @@
import { API, Error as ApiError } from "@/api/API";
import { API } from "@/api/API";
import { Schema as Inventory } from "@/api/Inventory";
import { toast } from "solid-toast";
import {
ErrorToastComponent,
CancelToastComponent,
} from "@/src/components/toast";
type OperationNames = keyof API;
type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
type ApiEnvelope<T> =
| {
status: "success";
data: T;
op_key: string;
}
| ApiError;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;
type ClanService<T extends ServiceNames> = Services[T];
type ClanServiceInstance<T extends ServiceNames> = NonNullable<
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
@@ -28,51 +21,87 @@ export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
type ClanOperations = Record<OperationNames, (str: string) => void>;
interface GtkResponse<T> {
result: T;
op_key: string;
function isMachine(obj: unknown): obj is Machine {
return (
!!obj &&
typeof obj === "object" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (obj as any).name === "string" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (obj as any).flake === "object" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (obj as any).flake.identifier === "string"
);
}
// Machine type with flake for API calls
interface Machine {
name: string;
flake: {
identifier: string;
};
}
interface BackendOpts {
logging?: { group: string | Machine };
}
interface BackendReturnType<K extends OperationNames> {
body: OperationResponse<K>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: Record<string, any>;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
backendOpts?: BackendOpts,
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
// return a rejected promise
return {
promise: Promise.resolve({
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
body: {
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
},
header: {},
}),
op_key: "noop",
};
}
let header: BackendOpts = {};
if (backendOpts != undefined) {
header = { ...backendOpts };
const group = backendOpts?.logging?.group;
if (group != undefined && isMachine(group)) {
header = {
logging: { group: group.flake.identifier + "#" + group.name },
};
}
}
const promise = (
window as unknown as Record<
OperationNames,
(
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
metadata: BackendOpts,
) => Promise<BackendReturnType<OperationNames>>
>
)[method](args) as Promise<OperationResponse<K>>;
)[method](args, header) as Promise<BackendReturnType<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (promise as any)._webviewMessageId as string;
@@ -82,7 +111,7 @@ const _callApi = <K extends OperationNames>(
const handleCancel = async <K extends OperationNames>(
ops_key: string,
orig_task: Promise<OperationResponse<K>>,
orig_task: Promise<BackendReturnType<K>>,
) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
@@ -102,7 +131,7 @@ const handleCancel = async <K extends OperationNames>(
});
const resp = await promise;
if (resp.status === "error") {
if (resp.body.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
@@ -124,10 +153,11 @@ const handleCancel = async <K extends OperationNames>(
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: BackendOpts,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args);
console.log("Calling API", method, args, backendOpts);
const { promise, op_key } = _callApi(method, args);
const { promise, op_key } = _callApi(method, args, backendOpts);
promise.catch((error) => {
toast.custom(
(t) => (
@@ -165,13 +195,14 @@ export const callApi = <K extends OperationNames>(
console.log("Not printing toast because operation was cancelled");
}
if (response.status === "error" && !cancelled) {
const body = response.body;
if (body.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + response.errors[0].message}
message={"Error: " + body.errors[0].message}
/>
),
{
@@ -181,7 +212,8 @@ export const callApi = <K extends OperationNames>(
} else {
toast.remove(toastId);
}
return response;
return body;
});
return { promise: new_promise, op_key: op_key };
};

View File

@@ -8,13 +8,23 @@ pkgs.clangStdenv.mkDerivation {
# We disallow remote connections from the UI on Linux
# TODO: Disallow remote connections on MacOS
src = pkgs.fetchFromGitHub {
owner = "clan-lol";
src = pkgs.fetchFromGitea {
domain = "git.clan.lol";
owner = "clan";
repo = "webview";
rev = "7d24f0192765b7e08f2d712fae90c046d08f318e";
hash = "sha256-yokVI9tFiEEU5M/S2xAeJOghqqiCvTelLo8WLKQZsSY=";
rev = "ef481aca8e531f6677258ca911c61aaaf71d2214";
hash = "sha256-KF9ESpo40z6VXyYsZCLWJAIh0RFe1Zy/Qw4k7cTpoYU=";
};
# @Mic92: Where is this revision coming from? I can't see it in any of the branches.
# I removed the icon python code for now
# src = pkgs.fetchFromGitHub {
# owner = "clan-lol";
# repo = "webview";
# rev = "7d24f0192765b7e08f2d712fae90c046d08f318e";
# hash = "sha256-yokVI9tFiEEU5M/S2xAeJOghqqiCvTelLo8WLKQZsSY=";
# };
outputs = [
"out"
"dev"

2
pkgs/clan-cli/api.py Normal file → Executable file
View File

@@ -1,3 +1,5 @@
#!/usr/bin/env python3
import importlib
import json
import pkgutil

View File

@@ -1,15 +1,36 @@
import logging
import threading
from dataclasses import dataclass
from clan_lib.api import API
log = logging.getLogger(__name__)
@dataclass
class WebThread:
thread: threading.Thread
stop_event: threading.Event
BAKEND_THREADS: dict[str, WebThread] | None = None
@API.register_abstract
def cancel_task(task_id: str) -> None:
"""Cancel a task by its op_key."""
msg = "cancel_task() is not implemented"
raise NotImplementedError(msg)
assert BAKEND_THREADS is not None, "Backend threads not initialized"
future = BAKEND_THREADS.get(task_id)
if future:
future.stop_event.set()
log.debug(f"Task with id {task_id} has been cancelled.")
else:
msg = f"Task with id {task_id} not found."
raise ValueError(msg)
@API.register_abstract
@API.register
def list_tasks() -> list[str]:
"""List all tasks."""
msg = "list_tasks() is not implemented"
raise NotImplementedError(msg)
assert BAKEND_THREADS is not None, "Backend threads not initialized"
return list(BAKEND_THREADS.keys())

View File

@@ -310,10 +310,13 @@ class LogManager:
base_dir: Path
def create_log_file(
self, func: Callable, op_key: str, group: str = "default"
self, func: Callable, op_key: str, group: str | None = None
) -> LogFile:
now_utc = datetime.datetime.now(tz=datetime.UTC)
if group is None:
group = "default"
log_file = LogFile(
op_key=op_key,
date_day=now_utc.strftime("%Y-%m-%d"),