clan-app: Add cancellable tasks

This commit is contained in:
Qubasa
2025-05-09 17:14:49 +02:00
parent 7f604e99bf
commit 38ea6515cf
17 changed files with 781 additions and 247 deletions

View File

@@ -20,6 +20,9 @@
},
{
"path": "../webview-lib"
},
{
"path": "../clan-cli/clan_lib"
}
],
"settings": {

View File

@@ -0,0 +1,17 @@
import logging
from clan_lib.api import ErrorDataClass, SuccessDataClass
log = logging.getLogger(__name__)
def cancel_task(
task_id: str, *, op_key: str
) -> SuccessDataClass[None] | ErrorDataClass:
"""Cancel a task by its op_key."""
log.info(f"Cancelling task with op_key: {task_id}")
return SuccessDataClass(
op_key=op_key,
data=None,
status="success",
)

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from clan_cli.custom_logger import setup_logging
from clan_lib.api import API
from clan_app.api.cancel import cancel_task
from clan_app.api.file_gtk import open_file
from clan_app.deps.webview.webview import Size, SizeHint, Webview
@@ -42,6 +43,8 @@ def app_run(app_opts: ClanAppOptions) -> int:
webview = Webview(debug=app_opts.debug)
API.overwrite_fn(open_file)
# breakpoint()
API.overwrite_fn(cancel_task)
webview.bind_jsonschema_api(API)
webview.size = Size(1280, 1024, SizeHint.NONE)
webview.navigate(content_uri)

View File

@@ -113,6 +113,7 @@ class Webview:
reconciled_arguments["op_key"] = seq.decode()
# TODO: We could remove the wrapper in the MethodRegistry
# and just call the method directly
result = wrap_method(**reconciled_arguments)
serialized = json.dumps(

View File

@@ -32,6 +32,11 @@ class FileRequest:
initial_folder: str | None = field(default=None)
@API.register_abstract
def cancel_task(task_id: str) -> None:
"""Cancel a task by its op_key."""
@API.register_abstract
def open_file(file_request: FileRequest) -> list[str] | None:
"""

View File

@@ -7,10 +7,15 @@ pkgs.clangStdenv.mkDerivation {
src = pkgs.fetchFromGitHub {
owner = "webview";
repo = "webview";
rev = "83a4b4a5bbcb4b0ba2ca3ee226c2da1414719106";
sha256 = "sha256-5R8kllvP2EBuDANIl07fxv/EcbPpYgeav8Wfz7Kt13c=";
rev = "f1a9d6b6fb8bcc2e266057224887a3d628f30f90";
sha256 = "sha256-sK7GXDbb2zEntWH5ylC2B39zW+gXvqQ1l843gvziDZo=";
};
# We add the function id to the promise to be able to cancel it through the UI
# We disallow remote connections from the UI on Linux
# TODO: Disallow remote connections on MacOS
patches = [ ./fixes.patch ];
outputs = [
"out"
"dev"

View File

@@ -0,0 +1,54 @@
diff --git a/core/include/webview/detail/backends/gtk_webkitgtk.hh b/core/include/webview/detail/backends/gtk_webkitgtk.hh
index f44db8f..b5657ca 100644
--- a/core/include/webview/detail/backends/gtk_webkitgtk.hh
+++ b/core/include/webview/detail/backends/gtk_webkitgtk.hh
@@ -303,6 +303,37 @@ private:
add_init_script("function(message) {\n\
return window.webkit.messageHandlers.__webview__.postMessage(message);\n\
}");
+
+
+ //===================MY CHANGES=========================
+ // TODO: Would be nice to have this configurable from the API.
+ auto on_decide_policy = +[] (WebKitWebView *,
+ WebKitPolicyDecision *decision,
+ WebKitPolicyDecisionType decision_type, gpointer) -> gboolean {
+ if (decision_type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) {
+ return FALSE; // Continue with the default handler
+ }
+
+ WebKitNavigationPolicyDecision * navigation_decision = WEBKIT_NAVIGATION_POLICY_DECISION (decision);
+ WebKitNavigationAction * navigation_action = webkit_navigation_policy_decision_get_navigation_action (navigation_decision);
+ WebKitURIRequest * request = webkit_navigation_action_get_request (navigation_action);
+ const char * uri = webkit_uri_request_get_uri (request);
+
+ if (g_str_has_prefix(uri, "file://") ||
+ g_str_has_prefix(uri, "http://localhost") ||
+ g_str_has_prefix(uri, "http://127.0.0.1") ||
+ g_str_has_prefix(uri, "http://[::1]")) {
+ printf("Allowing %s URI\n", uri);
+ return FALSE; // Continue with the default handler
+ } else {
+ printf("Blocking %s URI at %s:%d\n", uri, __FILE__, __LINE__);
+ webkit_policy_decision_ignore(decision);
+ return TRUE; // Stop the default handler
+ }
+ };
+ g_signal_connect(GTK_WIDGET(m_webview), "decide-policy",
+ G_CALLBACK(on_decide_policy), this);
+ //============END=========
}
void window_settings(bool debug) {
diff --git a/core/include/webview/detail/engine_base.hh b/core/include/webview/detail/engine_base.hh
index 01c8d29..8ea5622 100644
--- a/core/include/webview/detail/engine_base.hh
+++ b/core/include/webview/detail/engine_base.hh
@@ -232,6 +232,7 @@ protected:
var promise = new Promise(function(resolve, reject) {\n\
_promises[_id] = { resolve, reject };\n\
});\n\
+ promise._webviewMessageId = _id;\n\
this.post(JSON.stringify({\n\
id: _id,\n\
method: method,\n\

View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"@floating-ui/dom": "^1.6.8",
"@modular-forms/solid": "^0.21.0",
"@solid-primitives/storage": "^3.7.1",
@@ -63,7 +64,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -91,7 +91,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -106,7 +105,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.1.tgz",
"integrity": "sha512-Q+E+rd/yBzNQhXkG+zQnF58e4zoZfBedaxwzPmicKsiK3nt8iJYrSrDbjwFFDGC4f+rPafqRaPH6TsDoSvMf7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -116,7 +114,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz",
"integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
@@ -147,7 +144,6 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -157,7 +153,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
"integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.1",
@@ -174,7 +169,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.1.tgz",
"integrity": "sha512-2YaDd/Rd9E598B5+WIc8wJPmWETiiJXFYVE60oX8FDohv7rAUU3CQj+A1MgeEmcsk2+dQuEjIe/GDvig0SqL4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.1",
@@ -191,7 +185,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -201,7 +194,6 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -211,7 +203,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -225,7 +216,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz",
"integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
@@ -243,7 +233,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -253,7 +242,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -263,7 +251,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -273,7 +260,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -283,7 +269,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
"integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.1",
@@ -297,7 +282,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz",
"integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.1"
@@ -309,6 +293,21 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
"integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
@@ -345,7 +344,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz",
"integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -360,7 +358,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
"integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -379,7 +376,6 @@
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -389,7 +385,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
"integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -1257,7 +1252,6 @@
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
@@ -1272,7 +1266,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1282,7 +1275,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1292,14 +1284,12 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -2840,7 +2830,6 @@
"version": "4.24.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2916,7 +2905,6 @@
"version": "1.0.30001717",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz",
"integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -3104,7 +3092,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/corvu": {
@@ -3506,7 +3493,6 @@
"version": "1.5.150",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.150.tgz",
"integrity": "sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -3620,7 +3606,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4065,7 +4050,6 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -4581,7 +4565,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4641,7 +4624,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -4672,7 +4654,6 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -5577,7 +5558,6 @@
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -7424,7 +7404,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -7993,7 +7972,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {

View File

@@ -35,6 +35,7 @@
"vitest": "^1.6.0"
},
"dependencies": {
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"@floating-ui/dom": "^1.6.8",
"@modular-forms/solid": "^0.21.0",
"@solid-primitives/storage": "^3.7.1",

View File

@@ -1,58 +0,0 @@
import schema from "@/api/API.json" assert { type: "json" };
import { API, Error } from "@/api/API";
import { nanoid } from "nanoid";
import { Schema as Inventory } from "@/api/Inventory";
export type OperationNames = keyof API;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ApiEnvelope<T> =
| {
status: "success";
data: T;
op_key: string;
}
| Error;
export type Services = NonNullable<Inventory["services"]>;
export type ServiceNames = keyof Services;
export type ClanService<T extends ServiceNames> = Services[T];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
export type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
export type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
export type ClanOperations = Record<OperationNames, (str: string) => void>;
export interface GtkResponse<T> {
result: T;
op_key: string;
}
export const callApi = async <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): Promise<OperationResponse<K>> => {
console.log("Calling API", method, args);
const response = await (
window as unknown as Record<
OperationNames,
(
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
>
)[method](args);
return response as OperationResponse<K>;
};

View File

@@ -0,0 +1,132 @@
import schema from "@/api/API.json" with { type: "json" };
import { API, Error } from "@/api/API";
import { nanoid } from "nanoid";
import { Schema as Inventory } from "@/api/Inventory";
import { toast, Toast } from "solid-toast";
import {
ErrorToastComponent,
InfoToastComponent,
} from "@/src/components/toast";
export type OperationNames = keyof API;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ApiEnvelope<T> =
| {
status: "success";
data: T;
op_key: string;
}
| Error;
export type Services = NonNullable<Inventory["services"]>;
export type ServiceNames = keyof Services;
export type ClanService<T extends ServiceNames> = Services[T];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
export type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
export type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
export type ClanOperations = Record<OperationNames, (str: string) => void>;
export interface GtkResponse<T> {
result: T;
op_key: string;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
const promise = (
window as unknown as Record<
OperationNames,
(
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
>
)[method](args) as Promise<OperationResponse<K>>;
const op_key = (promise as any)._webviewMessageId as string;
debugger;
return { promise, op_key };
};
const handleCancel = async (ops_key: string) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
const resp = await promise;
if (resp.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Failed to cancel operation: " + ops_key}
/>
),
{
duration: 5000,
},
);
} else {
toast.custom(
(t) => (
<InfoToastComponent t={t} message={"Canceled operation: " + ops_key} />
),
{
duration: 5000,
},
);
}
console.log("Cancel response: ", resp);
};
export const callApi = async <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): Promise<OperationResponse<K>> => {
console.log("Calling API", method, args);
const { promise, op_key } = _callApi(method, args);
const toastId = toast.custom(
(
t, // t is the Toast object, t.id is the id of THIS toast instance
) => (
<InfoToastComponent
t={t}
message={"Exectuting " + method}
onCancel={handleCancel.bind(null, op_key)}
/>
),
{
duration: Infinity,
},
);
const response = await promise;
if (response.status === "error") {
toast.remove(toastId);
toast.error(
<div>
{response.errors.map((err) => (
<p>{err.message}</p>
))}
</div>,
{
duration: 5000,
},
);
} else {
toast.remove(toastId);
}
return response as OperationResponse<K>;
};

View File

@@ -1,7 +1,7 @@
import cx from "classnames";
import { createMemo, JSX, Show, splitProps } from "solid-js";
interface FileInputProps {
export interface FileInputProps {
ref: (element: HTMLInputElement) => void;
name: string;
value?: File[] | File;

View File

@@ -0,0 +1,196 @@
import { FileInput, type FileInputProps } from "@/src/components/FileInput"; // Assuming FileInput can take a ref and has onClick
import { Typography } from "@/src/components/Typography";
import Fieldset from "@/src/Form/fieldset";
import Icon from "@/src/components/icon"; // For displaying file icons
import { callApi } from "@/src/api";
import type {
FieldComponent,
FieldValues,
FieldName,
} from "@modular-forms/solid";
import { Show, For, type Component, type JSX } from "solid-js";
// Types for the file dialog options passed to callApi
interface FileRequestFilter {
patterns: string[];
mime_types?: string[];
}
export interface FileDialogOptions {
title: string;
filters?: FileRequestFilter;
initial_folder?: string;
}
// Props for the CustomFileField component
interface FileSelectorOpts<
TForm extends FieldValues,
TFieldName extends FieldName<TForm>,
> {
Field: FieldComponent<TForm>; // The Field component from createForm
name: TFieldName; // Name of the form field (e.g., "sshKeys", "profilePicture")
label: string; // Legend for Fieldset or main label for the input
description?: string | JSX.Element; // Optional description text
multiple?: boolean; // True if multiple files can be selected, false for single file
fileDialogOptions: FileDialogOptions; // Configuration for the custom file dialog
// Optional props for styling
inputClass?: string;
fileListClass?: string;
// You can add more specific props like `validate` if you want to pass them to Field
}
export const FileSelectorField: Component<FileSelectorOpts<any, any>> = (
props,
) => {
const {
Field,
name,
label,
description,
multiple = false,
fileDialogOptions,
inputClass,
fileListClass,
} = props;
// Ref to the underlying HTMLInputElement (assuming FileInput forwards refs or is simple)
let actualInputElement: HTMLInputElement | undefined;
const openAndSetFiles = async (event: MouseEvent) => {
event.preventDefault();
if (!actualInputElement) {
console.error(
"CustomFileField: Input element ref is not set. Cannot proceed.",
);
return;
}
const dataTransfer = new DataTransfer();
const mode = multiple ? "open_multiple_files" : "open_file";
try {
const response = await callApi("open_file", {
file_request: {
title: fileDialogOptions.title,
mode: mode,
filters: fileDialogOptions.filters,
initial_folder: fileDialogOptions.initial_folder,
},
});
if (
response.status === "success" &&
response.data &&
Array.isArray(response.data)
) {
(response.data as string[]).forEach((filename) => {
// Create File objects. Content is empty as we only have paths.
// Type might be generic or derived if possible.
dataTransfer.items.add(
new File([], filename, { type: "application/octet-stream" }),
);
});
} else if (response.status === "error") {
// Consider using a toast or other user notification for API errors
console.error("Error from open_file API:", response.errors);
}
} catch (error) {
console.error("Failed to call open_file API:", error);
// Consider using a toast here
}
// Set the FileList on the actual input element
Object.defineProperty(actualInputElement, "files", {
value: dataTransfer.files,
writable: true,
});
// Dispatch an 'input' event so modular-forms updates its state
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
actualInputElement.dispatchEvent(inputEvent);
// Optionally, dispatch 'change' if your forms setup relies more on it
// const changeEvent = new Event("change", { bubbles: true, cancelable: true });
// actualInputElement.dispatchEvent(changeEvent);
};
return (
<Fieldset legend={label}>
{description &&
(typeof description === "string" ? (
<Typography hierarchy="body" size="s" weight="medium" class="mb-2">
{description}
</Typography>
) : (
description
))}
<Field name={name} type={multiple ? "File[]" : "File"}>
{(field, fieldProps) => (
<>
{/*
This FileInput component should be clickable.
Its 'ref' needs to point to the actual <input type="file"> element.
If FileInput is complex, it might need an 'inputRef' prop or similar.
*/}
<FileInput
{...(fieldProps as FileInputProps)} // Spread modular-forms props
ref={(el: HTMLInputElement) => {
(fieldProps as any).ref(el); // Pass ref to modular-forms
actualInputElement = el; // Capture for local use
}}
class={inputClass}
multiple={multiple}
// The onClick here triggers our custom dialog logic
onClick={openAndSetFiles}
// The 'value' prop for a file input is not for displaying selected files directly.
// We'll display them below. FileInput might show placeholder text.
// value={undefined} // Explicitly not setting value from field.value here
error={field.error} // Display error from modular-forms
/>
{field.error && (
<Typography color="error" hierarchy="body" size="xs" class="mt-1">
{field.error}
</Typography>
)}
{/* Display the list of selected files */}
<Show
when={
field.value &&
(multiple
? (field.value as File[]).length > 0
: field.value instanceof File)
}
>
<div class={`mt-2 space-y-1 ${fileListClass || ""}`}>
<For
each={
multiple
? (field.value as File[])
: field.value instanceof File
? [field.value as File]
: []
}
>
{(file) => (
<div class="flex items-center justify-between rounded border border-def-1 bg-bg-2 p-2 text-sm">
<span class="truncate" title={file.name}>
<Icon icon="File" class="mr-2 inline-block" size={14} />
{file.name}
</span>
{/* A remove button per file is complex with FileList & modular-forms.
For now, clearing all files is simpler (e.g., via FileInput's own clear).
Or, the user re-selects files to change the selection. */}
</div>
)}
</For>
</div>
</Show>
</>
)}
</Field>
</Fieldset>
);
};

View File

@@ -0,0 +1,244 @@
import { toast, Toast } from "solid-toast"; // Make sure to import Toast type
import { Component, JSX } from "solid-js";
// --- Icon Components ---
const ErrorIcon: Component = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ "margin-right": "10px", "flex-shrink": "0" }}
>
<circle cx="12" cy="12" r="10" fill="#FF4D4F" />
<path
d="M12 7V13"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16.5" r="1.5" fill="white" />
</svg>
);
const InfoIcon: Component = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ "margin-right": "10px", "flex-shrink": "0" }}
>
<circle cx="12" cy="12" r="10" fill="#2196F3" />
<path
d="M12 11V17"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="8.5" r="1.5" fill="white" />
</svg>
);
const WarningIcon: Component = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ "margin-right": "10px", "flex-shrink": "0" }}
>
<path d="M12 2L22 21H2L12 2Z" fill="#FFC107" />
<path
d="M12 9V14"
stroke="#424242"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16.5" r="1" fill="#424242" />
</svg>
);
// --- Base Props and Styles ---
export interface BaseToastProps {
t: Toast;
message: string;
onCancel?: () => void; // Optional custom function on X click
}
const baseToastStyle: JSX.CSSProperties = {
display: "flex",
"align-items": "center",
"justify-content": "space-between", // To push X to the right
gap: "10px", // Space between content and close button
background: "#FFFFFF",
color: "#333333",
padding: "12px 16px",
"border-radius": "6px",
"box-shadow": "0 2px 8px rgba(0, 0, 0, 0.12)",
"font-family":
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
"font-size": "14px",
"line-height": "1.4",
"min-width": "280px",
"max-width": "450px",
};
const closeButtonStyle: JSX.CSSProperties = {
background: "none",
border: "none",
color: "red", // As per original example's X button
"font-size": "1.5em",
"font-weight": "bold",
cursor: "pointer",
padding: "0 0 0 10px", // Space to its left
"line-height": "1",
"align-self": "center", // Ensure vertical alignment
};
// --- Toast Component Definitions ---
// Error Toast
export interface ErrorToastProps extends BaseToastProps {}
export const ErrorToastComponent: Component<ErrorToastProps> = (props) => {
const handleCancelClick = () => {
if (props.onCancel) {
props.onCancel();
}
toast.dismiss(props.t.id);
};
return (
<div style={baseToastStyle}>
<div
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
>
<ErrorIcon />
<span>{props.message}</span>
</div>
<button
onClick={handleCancelClick}
style={closeButtonStyle}
aria-label="Close notification"
>
</button>
</div>
);
};
// Info Toast
export interface InfoToastProps extends BaseToastProps {}
export const InfoToastComponent: Component<InfoToastProps> = (props) => {
const handleCancelClick = () => {
if (props.onCancel) {
props.onCancel();
}
toast.dismiss(props.t.id);
};
return (
<div style={baseToastStyle}>
<div
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
>
<InfoIcon />
<span>{props.message}</span>
</div>
<button
onClick={handleCancelClick}
style={closeButtonStyle}
aria-label="Close notification"
>
</button>
</div>
);
};
// Warning Toast
export interface WarningToastProps extends BaseToastProps {}
export const WarningToastComponent: Component<WarningToastProps> = (props) => {
const handleCancelClick = () => {
if (props.onCancel) {
props.onCancel();
}
toast.dismiss(props.t.id);
};
return (
<div style={baseToastStyle}>
<div
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
>
<WarningIcon />
<span>{props.message}</span>
</div>
<button
onClick={handleCancelClick}
style={closeButtonStyle}
aria-label="Close notification"
>
</button>
</div>
);
};
// --- Example Usage ---
/*
import { toast } from 'solid-toast';
import {
ErrorToastComponent,
InfoToastComponent,
WarningToastComponent
} from './your-toast-components-file'; // Adjust path as necessary
const logCancel = (type: string) => console.log(`${type} toast cancelled by user.`);
// Function to show an error toast
export const showErrorToast = (message: string) => {
toast.custom((t) => (
<ErrorToastComponent
t={t}
message={message}
onCancel={() => logCancel('Error')}
/>
), { duration: Infinity }); // Use Infinity duration if you want it to only close on X click
};
// Function to show an info toast
export const showInfoToast = (message: string) => {
toast.custom((t) => (
<InfoToastComponent
t={t}
message={message}
// onCancel not provided, so only dismisses
/>
), { duration: 6000 }); // Or some default duration
};
// Function to show a warning toast
export const showWarningToast = (message: string) => {
toast.custom((t) => (
<WarningToastComponent
t={t}
message={message}
onCancel={() => alert('Warning toast was cancelled!')}
/>
), { duration: Infinity });
};
// How to use them:
// showErrorToast("Target IP must be provided.");
// showInfoToast("Your profile has been updated successfully.");
// showWarningToast("Your session is about to expire in 5 minutes.");
*/

View File

@@ -1,11 +1,9 @@
import { callApi } from "@/src/api";
import { Button } from "@/src/components/button";
import { FileInput } from "@/src/components/FileInput";
// Icon is used in CustomFileField, ensure it's available or remove if not needed there
import Icon from "@/src/components/icon";
import { Typography } from "@/src/components/Typography";
import { Header } from "@/src/layout/header";
import { SelectInput } from "@/src/Form/fields/Select";
import { TextInput } from "@/src/Form/fields/TextInput";
import {
@@ -17,20 +15,26 @@ import {
getValues,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Show } from "solid-js";
import { createEffect, createSignal, For, Show } from "solid-js"; // For, Show might not be needed directly here now
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";
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
import Accordion from "@/src/components/accordion";
// Import the new generic component
import {
FileSelectorField,
type FileDialogOptions,
} from "@/src/components/fileSelect"; // Adjust path
interface Wifi extends FieldValues {
ssid: string;
password: string;
}
interface FlashFormValues extends FieldValues {
export interface FlashFormValues extends FieldValues {
machine: {
devicePath: string;
flake: string;
@@ -39,7 +43,7 @@ interface FlashFormValues extends FieldValues {
language: string;
keymap: string;
wifi: Wifi[];
sshKeys: File[];
sshKeys: File[]; // This field will use CustomFileField
}
export const Flash = () => {
@@ -51,15 +55,15 @@ export const Flash = () => {
},
language: "en_US.UTF-8",
keymap: "en",
// sshKeys: [] // Initial value for sshKeys (optional, modular-forms handles undefined)
},
});
/* ==== WIFI NETWORK ==== */
/* ==== WIFI NETWORK (logic remains the same) ==== */
const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]);
const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>(
[],
);
createEffect(() => {
const formWifi = getValue(formStore, "wifi");
if (formWifi !== undefined) {
@@ -67,7 +71,6 @@ export const Flash = () => {
setPasswordVisibility(new Array(formWifi.length).fill(false));
}
});
const addWifiNetwork = () => {
setWifiNetworks((c) => {
const res = [...c, { ssid: "", password: "" }];
@@ -76,7 +79,6 @@ export const Flash = () => {
});
setPasswordVisibility((c) => [...c, false]);
};
const removeWifiNetwork = (index: number) => {
const updatedNetworks = wifiNetworks().filter((_, i) => i !== index);
setWifiNetworks(updatedNetworks);
@@ -86,7 +88,6 @@ export const Flash = () => {
setPasswordVisibility(updatedVisibility);
setValue(formStore, "wifi", updatedNetworks);
};
const togglePasswordVisibility = (index: number) => {
const updatedVisibility = [...passwordVisibility()];
updatedVisibility[index] = !updatedVisibility[index];
@@ -123,40 +124,33 @@ export const Flash = () => {
},
staleTime: Infinity,
}));
/**
* Opens the custom file dialog
* Returns a native FileList to allow interaction with the native input type="file"
*/
const selectSshKeys = async (): Promise<FileList> => {
const dataTransfer = new DataTransfer();
const response = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_multiple_files",
// Define the options for the SSH key file dialog
const sshKeyDialogOptions: FileDialogOptions = {
title: "Select SSH Public Key(s)",
filters: { patterns: ["*.pub"] },
initial_folder: "~/.ssh",
},
});
if (response.status === "success" && response.data) {
// Add synthetic files to the DataTransfer object
// FileList cannot be instantiated directly.
response.data.forEach((filename) => {
dataTransfer.items.add(new File([], filename));
});
}
return dataTransfer.files;
};
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [isFlashing, setFlashing] = createSignal(false);
const handleSubmit = (values: FlashFormValues) => {
// Basic check for sshKeys, could add to modular-forms validation
if (!values.sshKeys || values.sshKeys.length === 0) {
toast.error("Please select at least one SSH key.");
return;
}
setConfirmOpen(true);
};
const handleConfirm = async () => {
// Wait for the flash to complete
const values = getValues(formStore) as FlashFormValues;
// Additional check, though handleSubmit should catch it
if (!values.sshKeys || values.sshKeys.length === 0) {
toast.error("SSH keys are missing. Cannot proceed with flash.");
setConfirmOpen(false);
return;
}
setFlashing(true);
console.log("Confirmed flash:", values);
try {
@@ -173,6 +167,7 @@ export const Flash = () => {
system_config: {
language: values.language,
keymap: values.keymap,
// Ensure sshKeys is correctly mapped (File[] to string[])
ssh_keys_path: values.sshKeys.map((file) => file.name),
},
dry_run: false,
@@ -202,6 +197,7 @@ export const Flash = () => {
handleClose={() => !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
@@ -236,54 +232,22 @@ export const Flash = () => {
</div>
</Modal>
<div class="w-full self-stretch p-8">
{/* <Typography tag="p" hierarchy="body" size="default" color="primary">
USB Utility image.
</Typography>
<Typography tag="p" hierarchy="body" size="default" color="secondary">
Will make bootstrapping new machines easier by providing secure remote
connection to any machine when plugged in.
</Typography> */}
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Fieldset legend="Authorized SSH Keys">
<Typography hierarchy="body" size="s" weight="medium">
Provide your SSH public key. For secure and passwordless SSH
connections.
</Typography>
<Field name="sshKeys" type="File[]">
{(field, props) => (
<>
<FileInput
{...props}
onClick={async (event) => {
event.preventDefault(); // Prevent the native file dialog from opening
const input = event.target;
const files = await selectSshKeys();
// Set the files
Object.defineProperty(input, "files", {
value: files,
writable: true,
});
// Define the files property on the input element
const changeEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(changeEvent);
}}
value={field.value}
error={field.error}
//helperText="Provide your SSH public key. For secure and passwordless SSH connections."
//label="Authorized SSH Keys"
multiple
<FileSelectorField
Field={Field}
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
label="Authorized SSH Keys"
description="Provide your SSH public key(s) for secure, passwordless connections. (.pub files)"
multiple={true} // Allow multiple SSH keys
fileDialogOptions={sshKeyDialogOptions}
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
// e.g. validate={[required("At least one SSH key is required.")]}
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
/>
</>
)}
</Field>
</Fieldset>
<Fieldset legend="General">
<Field name="disk" validate={[required("This field is required")]}>
{(field, props) => (
@@ -322,6 +286,7 @@ export const Flash = () => {
</Fieldset>
<Fieldset legend="Network Settings">
{/* ... Network settings as before ... */}
<FieldLayout
label={<InputLabel>Networks</InputLabel>}
field={
@@ -338,6 +303,7 @@ export const Flash = () => {
</div>
}
/>
{/* TODO: You would render the actual WiFi input fields here using a <For> loop over wifiNetworks() signal */}
</Fieldset>
<Accordion title="Advanced">
@@ -445,20 +411,23 @@ export const Flash = () => {
</Field>
</Fieldset>
</Accordion>
<div class="mt-2 flex justify-end pt-2">
<Button
class="self-end"
type="submit"
disabled={formStore.submitting}
disabled={formStore.submitting || isFlashing()}
startIcon={
formStore.submitting ? (
formStore.submitting || isFlashing() ? (
<Icon icon="Load" />
) : (
<Icon icon="Flash" />
)
}
>
{formStore.submitting ? "Flashing..." : "Flash Installer"}
{formStore.submitting || isFlashing()
? "Flashing..."
: "Flash Installer"}
</Button>
</div>
</Form>

View File

@@ -28,7 +28,10 @@ import { SummaryStep } from "./install/summary-step";
import cx from "classnames";
import { VarsStep, VarsValues } from "./install/vars-step";
import Fieldset from "@/src/Form/fieldset";
import {
FileSelectorField,
type FileDialogOptions,
} from "@/src/components/fileSelect";
type MachineFormInterface = MachineData & {
sshKey?: File;
disk?: string;
@@ -50,6 +53,7 @@ export interface AllStepsValues extends FieldValues {
"2": DiskValues;
"3": VarsValues;
"4": NonNullable<unknown>;
sshKey?: File;
}
const LoadingBar = () => (
@@ -104,9 +108,6 @@ const InstallMachine = (props: InstallMachineProps) => {
return;
}
const loading_toast = toast.loading(
"Installing machine. Grab coffee (15min)...",
);
setIsInstalling(true);
// props.machine.disk_
@@ -125,16 +126,6 @@ const InstallMachine = (props: InstallMachineProps) => {
schema_name: diskValues.schema,
force: true,
});
if (disk_response.status === "error") {
toast.error(
`Failed to set disk schema: ${disk_response.errors[0].message}`,
);
setProgressText(
"Failed to set disk schema. \n" + disk_response.errors[0].message,
);
return;
}
}
setProgressText("Installing machine ... (2/5)");
@@ -147,6 +138,7 @@ const InstallMachine = (props: InstallMachineProps) => {
identifier: curr_uri,
},
override_target_host: target,
private_key: values.sshKey?.name,
},
password: "",
},
@@ -164,24 +156,6 @@ const InstallMachine = (props: InstallMachineProps) => {
await sleep(10 * 1000);
const installResponse = await installPromise;
toast.dismiss(loading_toast);
if (installResponse.status === "error") {
toast.error("Failed to install machine");
setIsDone(true);
setProgressText(
"Failed to install machine. \n" + installResponse.errors[0].message,
);
}
if (installResponse.status === "success") {
toast.success("Machine installed successfully");
setIsDone(true);
setProgressText(
"Machine installed successfully. Please unplug the usb stick and reboot the system.",
);
}
};
const [step, setStep] = createSignal<StepIdx>("1");
@@ -431,14 +405,6 @@ const MachineForm = (props: MachineDetailsProps) => {
),
},
});
if (machine_response.status === "error") {
toast.error(
`Failed to set machine: ${machine_response.errors[0].message}`,
);
}
if (machine_response.status === "success") {
toast.success("Machine set successfully");
}
return null;
};
@@ -461,9 +427,8 @@ const MachineForm = (props: MachineDetailsProps) => {
}));
const handleUpdateButton = async () => {
const t = toast.loading("Checking for generators...");
await generatorsQuery.refetch();
toast.dismiss(t);
if (generatorsQuery.data?.length !== 0) {
navigate(`/machines/${machineName()}/vars`);
} else {
@@ -489,7 +454,6 @@ const MachineForm = (props: MachineDetailsProps) => {
const target = targetHost();
const loading_toast = toast.loading("Updating machine...");
setIsUpdating(true);
const r = await callApi("update_machines", {
base_path: curr_uri,
@@ -502,15 +466,6 @@ const MachineForm = (props: MachineDetailsProps) => {
},
],
});
setIsUpdating(false);
toast.dismiss(loading_toast);
if (r.status === "error") {
toast.error("Failed to update machine");
}
if (r.status === "success") {
toast.success("Machine updated successfully");
}
};
createEffect(() => {
@@ -666,6 +621,22 @@ const MachineForm = (props: MachineDetailsProps) => {
/>
)}
</Field>
<FileSelectorField
Field={Field}
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
label="SSH Private Key"
description="Provide your SSH private key for secure, passwordless connections."
multiple={false}
fileDialogOptions={
{
title: "Select SSH Keys",
initial_folder: "~/.ssh",
} as FileDialogOptions
}
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
// e.g. validate={[required("At least one SSH key is required.")]}
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
/>
</Fieldset>
</Accordion>

View File

@@ -15,15 +15,19 @@ import {
setValue,
} from "@modular-forms/solid";
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
import toast from "solid-toast";
import { TextInput } from "@/src/Form/fields";
import { createQuery } from "@tanstack/solid-query";
import { Badge } from "@/src/components/badge";
import { Group } from "@/src/components/group";
import {
FileSelectorField,
type FileDialogOptions,
} from "@/src/components/fileSelect";
export type HardwareValues = FieldValues & {
report: boolean;
target: string;
sshKey?: File;
};
export interface StepProps<T> {
@@ -75,21 +79,21 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
const curr_uri = activeURI();
if (!curr_uri) return;
const loading_toast = toast.loading("Generating hardware report...");
await validate(formStore, "target");
const target = getValue(formStore, "target");
const sshFile = getValue(formStore, "sshKey") as File | undefined;
if (!target) {
toast.error("Target ip must be provided");
console.error("Target is not set");
return;
}
setIsGenerating(true);
const r = await callApi("generate_machine_hardware_info", {
opts: {
machine: {
name: props.machine_id,
override_target_host: target,
private_key: sshFile?.name,
flake: {
identifier: curr_uri,
},
@@ -97,16 +101,9 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
backend: "nixos-facter",
},
});
setIsGenerating(false);
toast.dismiss(loading_toast);
// TODO: refresh the machine details
if (r.status === "error") {
toast.error(`Failed to generate report. ${r.errors[0].message}`);
}
if (r.status === "success") {
toast.success("Report generated successfully");
}
hwReportQuery.refetch();
submit(formStore);
};
@@ -128,6 +125,22 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
/>
)}
</Field>
<FileSelectorField
Field={Field}
name="sshKey" // Corresponds to FlashFormValues.sshKeys
label="SSH Private Key"
description="Provide your SSH private key for secure, passwordless connections."
multiple={false}
fileDialogOptions={
{
title: "Select SSH Keys",
initial_folder: "~/.ssh",
} as FileDialogOptions
}
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
// e.g. validate={[required("At least one SSH key is required.")]}
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
/>
</Group>
<Group>
<Field