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:
@@ -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 ''
|
||||
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
5
pkgs/clan-app/pygdb.sh
Executable 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 "$@"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
1
pkgs/clan-app/ui-2d/package.json
Symbolic link
1
pkgs/clan-app/ui-2d/package.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../ui/package.json
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ApiTester = () => {
|
||||
return await callApi(
|
||||
values.endpoint as keyof API,
|
||||
JSON.parse(values.payload || "{}"),
|
||||
);
|
||||
).promise;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
|
||||
@@ -27,5 +27,5 @@
|
||||
}
|
||||
|
||||
.button--dark-active:active {
|
||||
@apply active:border-secondary-900 active:shadow-button-primary-active;
|
||||
@apply active:border-secondary-900;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
39
pkgs/clan-app/ui-2d/src/components/SimpleModal.tsx
Normal file
39
pkgs/clan-app/ui-2d/src/components/SimpleModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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); */
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
2
pkgs/clan-cli/api.py
Normal file → Executable file
@@ -1,3 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import pkgutil
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user