From 334fe45adcb31d22be36cc73d950e568da067acd Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 23 Jul 2025 16:41:24 +0200 Subject: [PATCH 01/15] ui/cubes: add labels --- .../ui/src/components/Sidebar/Sidebar.css | 2 +- .../ui/src/components/Sidebar/SidebarPane.css | 2 +- pkgs/clan-app/ui/src/scene/cubes.css | 6 +++++ pkgs/clan-app/ui/src/scene/cubes.tsx | 26 +++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Sidebar/Sidebar.css b/pkgs/clan-app/ui/src/components/Sidebar/Sidebar.css index 7884eae7d..2d19e39b9 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/Sidebar.css +++ b/pkgs/clan-app/ui/src/components/Sidebar/Sidebar.css @@ -1,5 +1,5 @@ div.sidebar { - @apply w-60 border-none; + @apply w-60 border-none z-10; & > div.header { } diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css b/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css index 13dacc11c..793cc44a9 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css @@ -1,5 +1,5 @@ div.sidebar-pane { - @apply w-full max-w-60 border-none; + @apply w-full max-w-60 border-none z-10; & > div.header { @apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem]; diff --git a/pkgs/clan-app/ui/src/scene/cubes.css b/pkgs/clan-app/ui/src/scene/cubes.css index dd5175df0..563e6e0c3 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.css +++ b/pkgs/clan-app/ui/src/scene/cubes.css @@ -13,3 +13,9 @@ justify-content: center; align-items: center; } + + +.machine-label { + @apply text-white bg-fg-def-2 py-1 px-3 rounded-sm; + font-size: 0.8rem; +} \ No newline at end of file diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index 228123ba9..0626f9650 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -10,6 +10,10 @@ import "./cubes.css"; import * as THREE from "three"; import { MapControls } from "three/examples/jsm/controls/MapControls.js"; +import { + CSS2DRenderer, + CSS2DObject, +} from "three/examples/jsm/renderers/CSS2DRenderer.js"; import { Toolbar } from "../components/Toolbar/Toolbar"; import { ToolbarButton } from "../components/Toolbar/ToolbarButton"; @@ -77,6 +81,7 @@ export function CubeScene(props: { let scene: THREE.Scene; let camera: THREE.OrthographicCamera; let renderer: THREE.WebGLRenderer; + let labelRenderer: CSS2DRenderer; let floor: THREE.Mesh; let controls: MapControls; // Raycaster for clicking @@ -195,8 +200,11 @@ export function CubeScene(props: { renderer.autoClear = false; renderer.render(bgScene, bgCamera); + controls.update(); // optional; see note below + renderer.render(scene, camera); + labelRenderer.render(scene, camera); if (frameCount % 30 === 0) logMemoryUsage(); } @@ -523,6 +531,15 @@ export function CubeScene(props: { renderer.shadowMap.type = THREE.PCFSoftShadowMap; container.appendChild(renderer.domElement); + // Label renderer + labelRenderer = new CSS2DRenderer(); + labelRenderer.setSize(container.clientWidth, container.clientHeight); + labelRenderer.domElement.style.position = "absolute"; + labelRenderer.domElement.style.top = "0px"; + labelRenderer.domElement.style.pointerEvents = "none"; + labelRenderer.domElement.style.zIndex = "0"; + container.appendChild(labelRenderer.domElement); + controls = new MapControls(camera, renderer.domElement); controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled // Enable the context menu, @@ -716,6 +733,7 @@ export function CubeScene(props: { camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); + labelRenderer.setSize(container.clientWidth, container.clientHeight); // Update background shader resolution uniforms.resolution.value.set( @@ -791,6 +809,14 @@ export function CubeScene(props: { const baseMesh = createCubeBase([0, BASE_HEIGHT / 2, 0]); baseMesh.name = "base"; // Name for easy identification + const nameDiv = document.createElement("div"); + nameDiv.className = "machine-label"; + nameDiv.textContent = `${userData.id}`; + + const nameLabel = new CSS2DObject(nameDiv); + nameLabel.position.set(0, CUBE_Y + CUBE_SIZE / 2 + 0.2, 0); + cubeMesh.add(nameLabel); + // TODO: Destroy Group in onCleanup const group = new THREE.Group(); group.add(cubeMesh); From 94662b722d0cd00b7b5bdb688109b5bfed24eb37 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Thu, 24 Jul 2025 14:25:05 +0700 Subject: [PATCH 02/15] clan-lib: Remove injected "op_key" argument from all functions and do it over the threadcontext instead. Remove double threading in http server --- pkgs/clan-app/clan_app/api/api_bridge.py | 5 ++- pkgs/clan-app/clan_app/api/file_gtk.py | 15 +++++-- .../api/middleware/argument_parsing.py | 6 --- .../clan_app/deps/http/http_bridge.py | 40 ++++++++++++++--- pkgs/clan-app/ui/src/hooks/api.ts | 13 +++--- pkgs/clan-cli/clan_lib/api/__init__.py | 44 +++---------------- pkgs/clan-cli/clan_lib/async_run/__init__.py | 17 +++++++ 7 files changed, 80 insertions(+), 60 deletions(-) diff --git a/pkgs/clan-app/clan_app/api/api_bridge.py b/pkgs/clan-app/clan_app/api/api_bridge.py index cdbdee676..b1e38a697 100644 --- a/pkgs/clan-app/clan_app/api/api_bridge.py +++ b/pkgs/clan-app/clan_app/api/api_bridge.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from clan_lib.api import ApiResponse from clan_lib.api.tasks import WebThread -from clan_lib.async_run import set_should_cancel +from clan_lib.async_run import set_current_thread_opkey, set_should_cancel if TYPE_CHECKING: from .middleware import Middleware @@ -98,7 +98,7 @@ class ApiBridge(ABC): *, thread_name: str = "ApiBridgeThread", wait_for_completion: bool = False, - timeout: float = 60.0, + timeout: float = 60.0 * 60, # 1 hour default timeout ) -> None: """Process an API request in a separate thread with cancellation support. @@ -112,6 +112,7 @@ class ApiBridge(ABC): def thread_task(stop_event: threading.Event) -> None: set_should_cancel(lambda: stop_event.is_set()) + set_current_thread_opkey(op_key) try: log.debug( f"Processing {request.method_name} with args {request.args} " diff --git a/pkgs/clan-app/clan_app/api/file_gtk.py b/pkgs/clan-app/clan_app/api/file_gtk.py index a5e87e51d..9f2b0e097 100644 --- a/pkgs/clan-app/clan_app/api/file_gtk.py +++ b/pkgs/clan-app/clan_app/api/file_gtk.py @@ -9,6 +9,7 @@ gi.require_version("Gtk", "4.0") from clan_lib.api import ApiError, ErrorDataClass, SuccessDataClass from clan_lib.api.directory import FileRequest +from clan_lib.async_run import get_current_thread_opkey from clan_lib.clan.check import check_clan_valid from clan_lib.flake import Flake from gi.repository import Gio, GLib, Gtk @@ -24,7 +25,7 @@ def remove_none(_list: list) -> list: RESULT: dict[str, SuccessDataClass[list[str] | None] | ErrorDataClass] = {} -def get_clan_folder(*, op_key: str) -> SuccessDataClass[Flake] | ErrorDataClass: +def get_clan_folder() -> SuccessDataClass[Flake] | ErrorDataClass: """ Opens the clan folder using the GTK file dialog. Returns the path to the clan folder or an error if it fails. @@ -34,7 +35,10 @@ def get_clan_folder(*, op_key: str) -> SuccessDataClass[Flake] | ErrorDataClass: title="Select Clan Folder", initial_folder=str(Path.home()), ) - response = get_system_file(file_request, op_key=op_key) + + response = get_system_file(file_request) + + op_key = response.op_key if isinstance(response, ErrorDataClass): return response @@ -70,8 +74,13 @@ def get_clan_folder(*, op_key: str) -> SuccessDataClass[Flake] | ErrorDataClass: def get_system_file( - file_request: FileRequest, *, op_key: str + file_request: FileRequest, ) -> SuccessDataClass[list[str] | None] | ErrorDataClass: + op_key = get_current_thread_opkey() + + if not op_key: + msg = "No operation key found in the current thread context." + raise RuntimeError(msg) GLib.idle_add(gtk_open_file, file_request, op_key) while RESULT.get(op_key) is None: diff --git a/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py b/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py index 107271f91..6a1becbb0 100644 --- a/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py +++ b/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py @@ -21,18 +21,12 @@ class ArgumentParsingMiddleware(Middleware): # Convert dictionary arguments to dataclass instances reconciled_arguments = {} for k, v in context.request.args.items(): - if k == "op_key": - continue - # Get the expected argument type from the API arg_class = self.api.get_method_argtype(context.request.method_name, k) # Convert dictionary to dataclass instance reconciled_arguments[k] = from_dict(arg_class, v) - # Add op_key to arguments - reconciled_arguments["op_key"] = context.request.op_key - # Create a new request with reconciled arguments updated_request = BackendRequest( diff --git a/pkgs/clan-app/clan_app/deps/http/http_bridge.py b/pkgs/clan-app/clan_app/deps/http/http_bridge.py index 1a9cfda4b..883ce31cd 100644 --- a/pkgs/clan-app/clan_app/deps/http/http_bridge.py +++ b/pkgs/clan-app/clan_app/deps/http/http_bridge.py @@ -1,13 +1,22 @@ import json import logging +import threading import uuid from http.server import BaseHTTPRequestHandler from pathlib import Path from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -from clan_lib.api import MethodRegistry, SuccessDataClass, dataclass_to_dict +from clan_lib.api import ( + MethodRegistry, + SuccessDataClass, + dataclass_to_dict, +) from clan_lib.api.tasks import WebThread +from clan_lib.async_run import ( + set_current_thread_opkey, + set_should_cancel, +) from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse @@ -324,17 +333,34 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): msg = f"Operation key '{op_key}' is already in use. Please try again." raise ValueError(msg) + def process_request_in_thread( + self, + request: BackendRequest, + *, + thread_name: str = "ApiBridgeThread", + wait_for_completion: bool = False, + timeout: float = 60.0 * 60, # 1 hour default timeout + ) -> None: + pass + def _process_api_request_in_thread( self, api_request: BackendRequest, method_name: str ) -> None: """Process the API request in a separate thread.""" - # Use the inherited thread processing method - self.process_request_in_thread( - api_request, - thread_name="HttpThread", - wait_for_completion=True, - timeout=60.0, + stop_event = threading.Event() + request = api_request + op_key = request.op_key or "unknown" + set_should_cancel(lambda: stop_event.is_set()) + set_current_thread_opkey(op_key) + + curr_thread = threading.current_thread() + self.threads[op_key] = WebThread(thread=curr_thread, stop_event=stop_event) + + log.debug( + f"Processing {request.method_name} with args {request.args} " + f"and header {request.header}" ) + self.process_request(request) def log_message(self, format: str, *args: Any) -> None: # noqa: A002 """Override default logging to use our logger.""" diff --git a/pkgs/clan-app/ui/src/hooks/api.ts b/pkgs/clan-app/ui/src/hooks/api.ts index d84c24367..c164c9f75 100644 --- a/pkgs/clan-app/ui/src/hooks/api.ts +++ b/pkgs/clan-app/ui/src/hooks/api.ts @@ -20,6 +20,7 @@ export type SuccessData = SuccessQuery["data"]; interface SendHeaderType { logging?: { group_path: string[] }; + op_key?: string; } interface BackendSendType { body: OperationArgs; @@ -64,9 +65,14 @@ export const callApi = ( }; } - const req: BackendSendType = { + const op_key = backendOpts?.op_key ?? crypto.randomUUID(); + + let req: BackendSendType = { body: args, - header: backendOpts, + header: { + ...backendOpts, + op_key, + }, }; const result = ( @@ -78,9 +84,6 @@ export const callApi = ( > )[method](req) as Promise>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const op_key = (result as any)._webviewMessageId as string; - return { uuid: op_key, result: result.then(({ body }) => body), diff --git a/pkgs/clan-cli/clan_lib/api/__init__.py b/pkgs/clan-cli/clan_lib/api/__init__.py index c06996977..62df22996 100644 --- a/pkgs/clan-cli/clan_lib/api/__init__.py +++ b/pkgs/clan-cli/clan_lib/api/__init__.py @@ -15,6 +15,7 @@ from typing import ( ) from clan_lib.api.util import JSchemaTypeError +from clan_lib.async_run import get_current_thread_opkey from clan_lib.errors import ClanError from .serde import dataclass_to_dict, from_dict, sanitize_string @@ -54,26 +55,6 @@ class ErrorDataClass: ApiResponse = SuccessDataClass[ResponseDataType] | ErrorDataClass -def update_wrapper_signature(wrapper: Callable, wrapped: Callable) -> None: - sig = signature(wrapped) - params = list(sig.parameters.values()) - - # Add 'op_key' parameter - op_key_param = Parameter( - "op_key", - Parameter.KEYWORD_ONLY, - # we add a None default value so that typescript code gen drops the parameter - # FIXME: this is a hack, we should filter out op_key in the typescript code gen - default=None, - annotation=str, - ) - params.append(op_key_param) - - # Create a new signature - new_sig = sig.replace(parameters=params) - wrapper.__signature__ = new_sig # type: ignore - - class MethodRegistry: def __init__(self) -> None: self._orig_signature: dict[str, Signature] = {} @@ -130,18 +111,8 @@ API.register(get_system_file) fn_signature = signature(fn) abstract_signature = signature(self._registry[fn_name]) - # Remove the default argument of op_key from abstract_signature - # FIXME: This is a hack to make the signature comparison work - # because the other hack above where default value of op_key is None in the wrapper - abstract_params = list(abstract_signature.parameters.values()) - for i, param in enumerate(abstract_params): - if param.name == "op_key": - abstract_params[i] = param.replace(default=Parameter.empty) - break - abstract_signature = abstract_signature.replace(parameters=abstract_params) - if fn_signature != abstract_signature: - msg = f"Expected signature: {abstract_signature}\nActual signature: {fn_signature}" + msg = f"For function: {fn_name}. Expected signature: {abstract_signature}\nActual signature: {fn_signature}" raise ClanError(msg) self._registry[fn_name] = fn @@ -159,7 +130,11 @@ API.register(get_system_file) self._orig_signature[fn.__name__] = signature(fn) @wraps(fn) - def wrapper(*args: Any, op_key: str, **kwargs: Any) -> ApiResponse[T]: + def wrapper(*args: Any, **kwargs: Any) -> ApiResponse[T]: + op_key = get_current_thread_opkey() + if op_key is None: + msg = f"While executing {fn.__name__}. Middleware forgot to set_current_thread_opkey()" + raise RuntimeError(msg) try: data: T = fn(*args, **kwargs) return SuccessDataClass(status="success", data=data, op_key=op_key) @@ -196,11 +171,6 @@ API.register(get_system_file) orig_return_type = get_type_hints(fn).get("return") wrapper.__annotations__["return"] = ApiResponse[orig_return_type] # type: ignore - # Add additional argument for the operation key - wrapper.__annotations__["op_key"] = str # type: ignore - - update_wrapper_signature(wrapper, fn) - self._registry[fn.__name__] = wrapper return fn diff --git a/pkgs/clan-cli/clan_lib/async_run/__init__.py b/pkgs/clan-cli/clan_lib/async_run/__init__.py index 8d7f8d728..d6a03b681 100644 --- a/pkgs/clan-cli/clan_lib/async_run/__init__.py +++ b/pkgs/clan-cli/clan_lib/async_run/__init__.py @@ -74,6 +74,7 @@ class AsyncContext: should_cancel: Callable[[], bool] = ( lambda: False ) # Used to signal cancellation of task + op_key: str | None = None @dataclass @@ -90,6 +91,22 @@ class AsyncOpts: ASYNC_CTX_THREAD_LOCAL = threading.local() +def set_current_thread_opkey(op_key: str) -> None: + """ + Set the current thread's operation key. + """ + ctx = get_async_ctx() + ctx.op_key = op_key + + +def get_current_thread_opkey() -> str | None: + """ + Get the current thread's operation key. + """ + ctx = get_async_ctx() + return ctx.op_key + + def is_async_cancelled() -> bool: """ Check if the current task has been cancelled. From 59105bd1dab1a03bc54d19ad2b65edc43e6fd7a8 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Thu, 24 Jul 2025 09:41:34 +0200 Subject: [PATCH 03/15] =?UTF-8?q?docs/options:=20expose=20all=20clan=20opt?= =?UTF-8?q?ions=20in=20N=C3=BCschtOS=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/nix/options/flake-module.nix | 44 ++++++++++++++++++++----------- lib/modules/clan/interface.nix | 4 +-- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/nix/options/flake-module.nix b/docs/nix/options/flake-module.nix index 32fcf8eae..80b8bb653 100644 --- a/docs/nix/options/flake-module.nix +++ b/docs/nix/options/flake-module.nix @@ -114,9 +114,6 @@ in { options = { - _ = mkOption { - type = types.raw; - }; instances.${name} = lib.mkOption { inherit description; type = types.submodule { @@ -149,20 +146,29 @@ }; }; - mkScope = name: modules: { - inherit name; - modules = [ - { - _module.args = { inherit clanLib; }; - _file = "docs mkScope"; - } - { noInstanceOptions = true; } - ../../../lib/modules/inventoryClass/interface.nix - ] ++ mapAttrsToList fakeInstanceOptions modules; - urlPrefix = "https://github.com/nix-community/dream2nix/blob/main/"; - }; + docModules = [ + { + inherit self; + } + self.modules.clan.default + { + options.inventory = lib.mkOption { + type = types.submoduleWith { + modules = [ + { noInstanceOptions = true; } + ] ++ mapAttrsToList fakeInstanceOptions serviceModules; + }; + }; + } + ]; + in { + # Uncomment for debugging + # legacyPackages.docModules = lib.evalModules { + # modules = docModules; + # }; + packages = lib.optionalAttrs ((privateInputs ? nuschtos) || (inputs ? nuschtos)) { docs-options = (privateInputs.nuschtos or inputs.nuschtos) @@ -171,7 +177,13 @@ inherit baseHref; title = "Clan Options"; # scopes = mapAttrsToList mkScope serviceModules; - scopes = [ (mkScope "Clan Inventory" serviceModules) ]; + scopes = [ + { + name = "Clan"; + modules = docModules; + urlPrefix = "https://git.clan.lol/clan/clan-core/src/branch/main/"; + } + ]; }; }; }; diff --git a/lib/modules/clan/interface.nix b/lib/modules/clan/interface.nix index a9d52066e..a097f7344 100644 --- a/lib/modules/clan/interface.nix +++ b/lib/modules/clan/interface.nix @@ -229,8 +229,8 @@ in }; inventory = lib.mkOption { - type = types.submodule { - imports = [ + type = types.submoduleWith { + modules = [ { _module.args = { inherit clanLib; }; _file = "clan interface"; From 694059d3ce3e7ae42e29219a0f873b52a1d41e5f Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Thu, 24 Jul 2025 09:48:57 +0100 Subject: [PATCH 04/15] feat(ui): waiting for necessary queries before dropping clan loader --- .../ui/src/components/Sidebar/SidebarBody.tsx | 30 ++++++----- .../src/components/Sidebar/SidebarHeader.tsx | 4 +- pkgs/clan-app/ui/src/hooks/api.ts | 2 +- pkgs/clan-app/ui/src/queries/queries.ts | 6 ++- pkgs/clan-app/ui/src/routes/Clan/Clan.tsx | 50 +++++++++++++++---- 5 files changed, 63 insertions(+), 29 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx index ccd762208..a5757bacb 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx @@ -3,7 +3,7 @@ import { A } from "@solidjs/router"; import { Accordion } from "@kobalte/core/accordion"; import Icon from "../Icon/Icon"; import { Typography } from "@/src/components/Typography/Typography"; -import { For, Suspense } from "solid-js"; +import { For } from "solid-js"; import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; import { buildMachinePath, useClanURI } from "@/src/hooks/clan"; import { useMachinesQuery } from "@/src/queries/queries"; @@ -89,21 +89,19 @@ export const SidebarBody = (props: SidebarProps) => { - - - + diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.tsx index d22c75fc3..c0acc59db 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.tsx +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.tsx @@ -4,7 +4,7 @@ import { DropdownMenu } from "@kobalte/core/dropdown-menu"; import { useNavigate } from "@solidjs/router"; import { Typography } from "../Typography/Typography"; import { createSignal, For, Suspense } from "solid-js"; -import { useAllClanDetailsQuery } from "@/src/queries/queries"; +import { useClanListQuery } from "@/src/queries/queries"; import { navigateToClan, useClanURI } from "@/src/hooks/clan"; import { clanURIs } from "@/src/stores/clan"; @@ -15,7 +15,7 @@ export const SidebarHeader = () => { // get information about the current active clan const clanURI = useClanURI(); - const allClans = useAllClanDetailsQuery(clanURIs()); + const allClans = useClanListQuery(clanURIs()); const activeClan = () => allClans.find(({ data }) => data?.uri === clanURI); diff --git a/pkgs/clan-app/ui/src/hooks/api.ts b/pkgs/clan-app/ui/src/hooks/api.ts index c164c9f75..282edd77d 100644 --- a/pkgs/clan-app/ui/src/hooks/api.ts +++ b/pkgs/clan-app/ui/src/hooks/api.ts @@ -67,7 +67,7 @@ export const callApi = ( const op_key = backendOpts?.op_key ?? crypto.randomUUID(); - let req: BackendSendType = { + const req: BackendSendType = { body: args, header: { ...backendOpts, diff --git a/pkgs/clan-app/ui/src/queries/queries.ts b/pkgs/clan-app/ui/src/queries/queries.ts index 38e880400..387b2d764 100644 --- a/pkgs/clan-app/ui/src/queries/queries.ts +++ b/pkgs/clan-app/ui/src/queries/queries.ts @@ -3,8 +3,12 @@ import { callApi, SuccessData } from "../hooks/api"; import { encodeBase64 } from "@/src/hooks/clan"; export type ClanDetails = SuccessData<"get_clan_details">; +export type ClanDetailsWithURI = ClanDetails & { uri: string }; + export type ListMachines = SuccessData<"list_machines">; + export type MachinesQueryResult = UseQueryResult; +export type ClanListQueryResult = UseQueryResult[]; export const useMachinesQuery = (clanURI: string) => useQuery(() => ({ @@ -48,7 +52,7 @@ export const useClanDetailsQuery = (clanURI: string) => }, })); -export const useAllClanDetailsQuery = (clanURIs: string[]) => +export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult => useQueries(() => ({ queries: clanURIs.map((clanURI) => ({ queryKey: ["clans", encodeBase64(clanURI), "details"], diff --git a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx index c26a35e97..d6211fc05 100644 --- a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx +++ b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx @@ -15,9 +15,14 @@ import { useClanURI, } from "@/src/hooks/clan"; import { CubeScene } from "@/src/scene/cubes"; -import { MachinesQueryResult, useMachinesQuery } from "@/src/queries/queries"; +import { + ClanListQueryResult, + MachinesQueryResult, + useClanListQuery, + useMachinesQuery, +} from "@/src/queries/queries"; import { callApi } from "@/src/hooks/api"; -import { store, setStore } from "@/src/stores/clan"; +import { store, setStore, clanURIs } from "@/src/stores/clan"; import { produce } from "solid-js/store"; import { Button } from "@/src/components/Button/Button"; import { Splash } from "@/src/scene/splash"; @@ -42,10 +47,12 @@ export const Clan: Component = (props) => { interface CreateFormValues extends FieldValues { name: string; } + interface MockProps { onClose: () => void; onSubmit: (formValues: CreateFormValues) => void; } + const MockCreateMachine = (props: MockProps) => { let container: Node; @@ -173,7 +180,26 @@ const ClanSceneController = (props: RouteSectionProps) => { return ( - {({ query }) => { + {({ clansQuery, machinesQuery }) => { + // a combination of the individual clan details query status and the machines query status + // the cube scene needs the machines query, the sidebar needs the clans query and machines query results + // so we wait on both before removing the loader to avoid any loading artefacts + const isLoading = (): boolean => { + // check the machines query first + if (machinesQuery.isLoading) { + return true; + } + + // otherwise iterate the clans query and return early if we find a queries that is still loading + for (const query of clansQuery) { + if (query.isLoading) { + return true; + } + } + + return false; + }; + return ( <> @@ -217,7 +243,7 @@ const ClanSceneController = (props: RouteSectionProps) => { ghost onClick={() => { console.log("Refetching API"); - query.refetch(); + machinesQuery.refetch(); }} > Refetch API @@ -225,7 +251,9 @@ const ClanSceneController = (props: RouteSectionProps) => { {/* TODO: Add minimal display time */}
@@ -233,8 +261,8 @@ const ClanSceneController = (props: RouteSectionProps) => { { const clanURI = useClanURI(); @@ -268,10 +296,14 @@ const ClanSceneController = (props: RouteSectionProps) => { const SceneDataProvider = (props: { clanURI: string; - children: (sceneData: { query: MachinesQueryResult }) => JSX.Element; + children: (sceneData: { + clansQuery: ClanListQueryResult; + machinesQuery: MachinesQueryResult; + }) => JSX.Element; }) => { + const clansQuery = useClanListQuery(clanURIs()); const machinesQuery = useMachinesQuery(props.clanURI); // This component can be used to provide scene data or context if needed - return props.children({ query: machinesQuery }); + return props.children({ clansQuery, machinesQuery }); }; From fb5229a5f353b74ba5af0321f46456f1e80b6f6b Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 23 Jul 2025 17:43:05 +0200 Subject: [PATCH 05/15] ui/cubes: adjust label style --- pkgs/clan-app/ui/src/scene/cubes.css | 18 ++++++++++++++---- pkgs/clan-app/ui/src/scene/cubes.tsx | 6 +++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/pkgs/clan-app/ui/src/scene/cubes.css b/pkgs/clan-app/ui/src/scene/cubes.css index 563e6e0c3..d9d7a3f63 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.css +++ b/pkgs/clan-app/ui/src/scene/cubes.css @@ -14,8 +14,18 @@ align-items: center; } - .machine-label { - @apply text-white bg-fg-def-2 py-1 px-3 rounded-sm; - font-size: 0.8rem; -} \ No newline at end of file + @apply text-white bg-inv-4 py-1 px-2 rounded-sm; + font-size: 0.75rem; +} + +.machine-label::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #203637 transparent transparent transparent; +} diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index 0626f9650..a316c318a 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -563,7 +563,7 @@ export function CubeScene(props: { const ambientLight = new THREE.AmbientLight(0xffffff, 1); scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 1); + const directionalLight = new THREE.DirectionalLight(0xffffff, 2); // scene.add(new THREE.DirectionalLightHelper(directionalLight)); // scene.add(new THREE.CameraHelper(directionalLight.shadow.camera)); @@ -587,7 +587,7 @@ export function CubeScene(props: { directionalLight.shadow.camera.far = 2000; directionalLight.shadow.mapSize.width = 4096; // Higher resolution for sharper shadows directionalLight.shadow.mapSize.height = 4096; - directionalLight.shadow.radius = 0; // Hard shadows (low radius) + directionalLight.shadow.radius = 1; // Hard shadows (low radius) directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges scene.add(directionalLight); scene.add(directionalLight.target); @@ -814,7 +814,7 @@ export function CubeScene(props: { nameDiv.textContent = `${userData.id}`; const nameLabel = new CSS2DObject(nameDiv); - nameLabel.position.set(0, CUBE_Y + CUBE_SIZE / 2 + 0.2, 0); + nameLabel.position.set(0, CUBE_Y + CUBE_SIZE / 2 - 0.2, 0); cubeMesh.add(nameLabel); // TODO: Destroy Group in onCleanup From f18e70dda6255b4177d82341c9ad8b25389985e4 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Thu, 24 Jul 2025 10:23:43 +0100 Subject: [PATCH 06/15] fix(ui): increase z index for sidebar dropdown --- pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.css b/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.css index 8eb419e12..c1fcb541f 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.css +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.css @@ -39,7 +39,7 @@ div.sidebar-header { } .sidebar-dropdown-content { - @apply flex flex-col w-full px-2 py-1.5; + @apply flex flex-col w-full px-2 py-1.5 z-10; @apply bg-def-1 rounded-bl-md rounded-br-md; @apply border border-def-2; From b74aa31b876830a1617d24c3c1cad0f9d7ac4a9d Mon Sep 17 00:00:00 2001 From: Qubasa Date: Thu, 24 Jul 2025 17:29:09 +0700 Subject: [PATCH 07/15] clan-lib: Fix missing logging for flake.select execution --- docs/site/developer/contributing/debugging.md | 3 +- pkgs/clan-cli/clan_lib/flake/flake.py | 62 +++++++------------ 2 files changed, 24 insertions(+), 41 deletions(-) diff --git a/docs/site/developer/contributing/debugging.md b/docs/site/developer/contributing/debugging.md index a20ea8bca..a26c3bd1e 100644 --- a/docs/site/developer/contributing/debugging.md +++ b/docs/site/developer/contributing/debugging.md @@ -85,7 +85,8 @@ export CLAN_DEBUG_COMMANDS=1 These options help you pinpoint the source and context of print messages and debug logs during development. - +!!! Note + `CLAN_DEBUG_NIX_SELECTORS` and `CLAN_DEBUG_NIX_PREFETCH` will only print the command on a cache miss! ## Analyzing Performance diff --git a/pkgs/clan-cli/clan_lib/flake/flake.py b/pkgs/clan-cli/clan_lib/flake/flake.py index 1f20731ae..914c2f876 100644 --- a/pkgs/clan-cli/clan_lib/flake/flake.py +++ b/pkgs/clan-cli/clan_lib/flake/flake.py @@ -2,7 +2,6 @@ import json import logging import os import re -import textwrap from dataclasses import asdict, dataclass, field from enum import Enum from functools import cache @@ -729,7 +728,7 @@ class Flake: self.identifier, ] - trace_prefetch = os.environ.get("CLAN_DEBUG_NIX_PREFETCH", "0") == "1" + trace_prefetch = os.environ.get("CLAN_DEBUG_NIX_PREFETCH", False) == "1" if not trace_prefetch: log.debug(f"Prefetching flake {self.identifier}") flake_prefetch = run(nix_command(cmd), RunOpts(trace=trace_prefetch)) @@ -862,47 +861,11 @@ class Flake: ]; }} """ - if len(selectors) > 1 : - msg = textwrap.dedent(f""" - clan select "{selectors}" - """).lstrip("\n").rstrip("\n") - if os.environ.get("CLAN_DEBUG_NIX_SELECTORS"): - msg += textwrap.dedent(f""" - to debug run: - nix repl --expr 'rec {{ - flake = builtins.getFlake "{self.identifier}"; - selectLib = (builtins.getFlake "path:{select_source()}?narHash={select_hash}").lib; - query = [ - {" ".join( - [ - f"(selectLib.select ''{selector}'' flake)" - for selector in selectors - ] - )} - ]; - }}' - """).lstrip("\n") - log.debug(msg) - # fmt: on - elif len(selectors) == 1: - msg = textwrap.dedent(f""" - $ clan select "{selectors[0]}" - """).lstrip("\n").rstrip("\n") - if os.environ.get("CLAN_DEBUG_NIX_SELECTORS"): - msg += textwrap.dedent(f""" - to debug run: - nix repl --expr 'rec {{ - flake = builtins.getFlake "{self.identifier}"; - selectLib = (builtins.getFlake "path:{select_source()}?narHash={select_hash}").lib; - query = selectLib.select '"''{selectors[0]}''"' flake; - }}' - """).lstrip("\n") - log.debug(msg) - + trace = os.environ.get("CLAN_DEBUG_NIX_SELECTORS", False) == "1" build_output = Path( run( nix_build(["--expr", nix_code, *nix_options]), - RunOpts(log=Log.NONE, trace=False), + RunOpts(log=Log.NONE, trace=trace), ).stdout.strip() ) @@ -918,6 +881,22 @@ class Flake: if self.flake_cache_path: self._cache.save_to_file(self.flake_cache_path) + def log_selectors(self, selectors: list[str]) -> None: + if not selectors: + return + + if len(selectors) > 1: + log.debug("==== Printing multi selector command as multiple commands. ====") + + msg = "" + # Build base message + for selector in selectors: + msg += f'$ clan select "{selector}"' + if len(selectors) > 1: + msg += "\n" + + log.debug(msg) + def precache(self, selectors: list[str]) -> None: """ Ensures that the specified selectors are cached locally. @@ -929,6 +908,7 @@ class Flake: Args: selectors (list[str]): A list of attribute selectors to check and cache. """ + if self._cache is None: self.invalidate_cache() assert self._cache is not None @@ -956,6 +936,8 @@ class Flake: assert self._cache is not None assert self.flake_cache_path is not None + self.log_selectors([selector]) + if not self._cache.is_cached(selector): log.debug(f"Cache miss for {selector}") self.get_from_nix([selector]) From d3d1489829355bd4518be71623ae2cb3428709d6 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Thu, 24 Jul 2025 11:40:54 +0100 Subject: [PATCH 08/15] feat(ui): animate sidebar pane entry/exit --- .../ui/src/components/Sidebar/SidebarPane.css | 68 ++++++++++++++++++- .../ui/src/components/Sidebar/SidebarPane.tsx | 14 +++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css b/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css index 793cc44a9..bfdb7ef39 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.css @@ -1,5 +1,16 @@ div.sidebar-pane { - @apply w-full max-w-60 border-none z-10; + @apply border-none z-10; + + animation: sidebarPaneShow 250ms ease-in forwards; + + &.closing { + animation: sidebarPaneHide 250ms ease-out 300ms forwards; + + & > div.header > *, + & > div.body > * { + animation: sidebarFadeOut 250ms ease-out forwards; + } + } & > div.header { @apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem]; @@ -7,11 +18,17 @@ div.sidebar-pane { border-r-[1px] border-r-bg-inv-3 border-b-2 border-b-bg-inv-4 border-l-[1px] border-l-bg-inv-3; + background: linear-gradient( 90deg, theme(colors.bg.inv.3) 0%, theme(colors.bg.inv.4) 100% ); + + & > * { + @apply opacity-0; + animation: sidebarFadeIn 250ms ease-in 250ms forwards; + } } & > div.body { @@ -29,5 +46,54 @@ div.sidebar-pane { theme(colors.bg.inv.2) 0%, theme(colors.bg.inv.3) 100% ); + + & > * { + @apply opacity-0; + animation: sidebarFadeIn 250ms ease-in 350ms forwards; + } + } +} + +@keyframes sidebarPaneShow { + 0% { + @apply w-0; + @apply opacity-0; + } + 10% { + @apply w-8; + } + 30% { + @apply opacity-100; + } + 100% { + @apply w-60; + } +} + +@keyframes sidebarPaneHide { + 90% { + @apply w-8; + } + 100% { + @apply w-0; + @apply opacity-0; + } +} + +@keyframes sidebarFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes sidebarFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; } } diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.tsx index 53bba06bb..f80863c3b 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.tsx +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarPane.tsx @@ -1,8 +1,9 @@ -import { JSX } from "solid-js"; +import { createSignal, JSX } from "solid-js"; import "./SidebarPane.css"; import { Typography } from "@/src/components/Typography/Typography"; import Icon from "../Icon/Icon"; import { Button as KButton } from "@kobalte/core/button"; +import cx from "classnames"; export interface SidebarPaneProps { title: string; @@ -11,13 +12,20 @@ export interface SidebarPaneProps { } export const SidebarPane = (props: SidebarPaneProps) => { + const [closing, setClosing] = createSignal(false); + + const onClose = () => { + setClosing(true); + setTimeout(() => props.onClose(), 550); + }; + return ( -