From fdd69ce1b5ad58a23a9b9948ce0f02066cdc3fb7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 30 Mar 2026 13:35:02 +0100 Subject: [PATCH] fix: auth, container resilience, ISO build, gamepad polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: login disconnect — verify session before WebSocket connect - fix: 403 on app install — distinguish CSRF vs RBAC errors, only retry CSRF - fix: health monitor now watches ALL containers (removed skip list for backend services like nbxplorer, databases, UI containers) - fix: server.get-state added to CSRF-exempt list (read-only) - fix: ISO build includes container-specs.sh and lib/common.sh in rootfs so reconcile actually works on fresh installs - fix: gamepad nav — improved Server tab zone nav, focus styles, autofocus - chore: move L484 web-only apps to Services tab - chore: install store for cross-view install tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/api/rpc/mod.rs | 3 +- core/archipelago/src/health_monitor.rs | 24 ++---- core/archipelago/tests/orchestration_tests.rs | 19 +++-- image-recipe/build-auto-installer-iso.sh | 13 ++- neode-ui/src/api/rpc-client.ts | 21 +++-- neode-ui/src/composables/useControllerNav.ts | 56 ++++++++++--- neode-ui/src/stores/auth.ts | 18 +++- neode-ui/src/stores/install.ts | 82 +++++++++++++++++++ neode-ui/src/style.css | 43 +++------- neode-ui/src/views/Server.vue | 6 +- neode-ui/src/views/apps/appsConfig.ts | 3 + neode-ui/src/views/discover/AppGrid.vue | 2 +- neode-ui/src/views/discover/FeaturedApps.vue | 2 +- .../src/views/server/QuickActionsCard.vue | 10 +-- neode-ui/src/views/server/TorServicesCard.vue | 2 +- .../views/settings/ChangePasswordSection.vue | 2 +- 16 files changed, 218 insertions(+), 88 deletions(-) create mode 100644 neode-ui/src/stores/install.ts diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 290cff39..295bb155 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -220,7 +220,8 @@ impl RpcHandler { // Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery. // Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie). let csrf_exempt = matches!(rpc_req.method.as_str(), - "node-messages-received" | "server.echo" | "system.stats" | "tor.status" + "node-messages-received" | "server.echo" | "server.get-state" + | "system.stats" | "tor.status" | "tor.onion-addresses" | "federation.list-nodes" | "system.get-settings" | "system.get-node-key" | "system.get-metrics" | "system.get-version" ); diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 6897663c..b7092453 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -326,13 +326,9 @@ async fn check_containers() -> Vec { let containers: Vec = serde_json::from_str(&stdout).unwrap_or_default(); - // Backend services and one-shot init containers to skip - let skip = [ - "btcpay-db", "nbxplorer", "mempool-db", "mempool-api", - "penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey", - "penpot-mailcatch", "immich_postgres", "immich_redis", - "endurain-db", "nextcloud-db", - ]; + // Monitor ALL long-running containers for health — backend services (databases, + // nbxplorer, mempool-api) and UI containers need auto-restart too. + // Only skip ephemeral containers (build infrastructure, init one-shots). containers .iter() @@ -345,20 +341,16 @@ async fn check_containers() -> Vec { } })?; - let app_id = name - .strip_prefix("archy-") - .unwrap_or(&name) - .to_string(); - - if skip.contains(&app_id.as_str()) || app_id.ends_with("-ui") { - return None; - } - // Skip podman-compose infrastructure and one-shot init containers if name.starts_with("indeedhub-build_") || name.contains("-init") { return None; } + let app_id = name + .strip_prefix("archy-") + .unwrap_or(&name) + .to_string(); + let state = c.get("State") .and_then(|v| v.as_str()) .unwrap_or("unknown") diff --git a/core/archipelago/tests/orchestration_tests.rs b/core/archipelago/tests/orchestration_tests.rs index 8cece48f..86a4e5b6 100644 --- a/core/archipelago/tests/orchestration_tests.rs +++ b/core/archipelago/tests/orchestration_tests.rs @@ -359,28 +359,31 @@ mod health_monitor_logic { } #[test] - fn ui_containers_skipped() { + fn all_long_running_containers_monitored() { + // Health monitor now checks ALL containers except ephemeral build/init ones. + // Backend services and UI containers are monitored for auto-restart. let containers = vec![ ("bitcoin-knots", "exited"), ("archy-bitcoin-ui", "exited"), ("archy-lnd-ui", "exited"), ("grafana", "exited"), + ("nbxplorer", "exited"), + ("indeedhub-build_api_1", "exited"), + ("btcpay-init", "exited"), ]; - let skip_suffixes = ["-ui"]; - let skip_backends = ["btcpay-db", "nbxplorer", "mempool-db", "mempool-api"]; - let to_check: Vec<&str> = containers .iter() .filter(|(name, _)| { - let id = name.strip_prefix("archy-").unwrap_or(name); - !skip_suffixes.iter().any(|s| id.ends_with(s)) - && !skip_backends.contains(&id) + !name.starts_with("indeedhub-build_") && !name.contains("-init") }) .map(|(name, _)| *name) .collect(); - assert_eq!(to_check, vec!["bitcoin-knots", "grafana"]); + assert_eq!(to_check, vec![ + "bitcoin-knots", "archy-bitcoin-ui", "archy-lnd-ui", + "grafana", "nbxplorer", + ]); } #[test] diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index d9a1ddee..0c73450b 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -343,11 +343,13 @@ COPY archipelago-tor-helper.service /etc/systemd/system/archipelago-tor-helper.s COPY archipelago-tor-helper.path /etc/systemd/system/archipelago-tor-helper.path # Copy container doctor + reconcile scripts (referenced by the services above) -RUN mkdir -p /home/archipelago/archy/scripts +RUN mkdir -p /home/archipelago/archy/scripts/lib COPY container-doctor.sh /home/archipelago/archy/scripts/container-doctor.sh COPY reconcile-containers.sh /home/archipelago/archy/scripts/reconcile-containers.sh +COPY container-specs.sh /home/archipelago/archy/scripts/container-specs.sh COPY tor-helper.sh /opt/archipelago/scripts/tor-helper.sh -RUN chmod +x /home/archipelago/archy/scripts/*.sh /opt/archipelago/scripts/*.sh && \ +COPY lib/ /home/archipelago/archy/scripts/lib/ +RUN chmod +x /home/archipelago/archy/scripts/*.sh /home/archipelago/archy/scripts/lib/*.sh /opt/archipelago/scripts/*.sh && \ chown -R archipelago:archipelago /home/archipelago/archy # Enable services @@ -428,11 +430,16 @@ NGINXCONF cp "$SCRIPT_DIR/configs/archipelago-reconcile.service" "$WORK_DIR/archipelago-reconcile.service" cp "$SCRIPT_DIR/configs/archipelago-reconcile.timer" "$WORK_DIR/archipelago-reconcile.timer" # Copy the actual scripts the services reference - for s in container-doctor.sh reconcile-containers.sh tor-helper.sh; do + for s in container-doctor.sh reconcile-containers.sh container-specs.sh tor-helper.sh; do if [ -f "$SCRIPT_DIR/../scripts/$s" ]; then cp "$SCRIPT_DIR/../scripts/$s" "$WORK_DIR/$s" fi done + # Copy shared script library (mem_limit etc.) + if [ -d "$SCRIPT_DIR/../scripts/lib" ]; then + mkdir -p "$WORK_DIR/lib" + cp "$SCRIPT_DIR/../scripts/lib/"*.sh "$WORK_DIR/lib/" 2>/dev/null || true + fi echo " Using container doctor + reconcile timers from configs/" fi diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index c9bf3db5..41e20470 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -78,11 +78,22 @@ class RPCClient { } throw new Error('Session expired') } - // CSRF 403: retry twice after delay (cookie may have been - // updated by a concurrent Set-Cookie response not yet visible to JS) - if (response.status === 403 && attempt < maxRetries - 1) { - await new Promise((r) => setTimeout(r, 500)) - continue + // 403: read body to distinguish CSRF (retryable) from RBAC (permanent) + if (response.status === 403) { + let reason = '' + try { + const body: RPCResponse = await response.json() + reason = body.error?.message || '' + } catch { /* body parse failed */ } + + const isCsrf = !reason || reason.toLowerCase().includes('csrf') + if (isCsrf && attempt < maxRetries - 1) { + // CSRF mismatch — cookie may have been updated by a concurrent + // Set-Cookie response not yet visible to JS. Retry after delay. + await new Promise((r) => setTimeout(r, 500)) + continue + } + throw new Error(reason || `HTTP 403: Forbidden`) } const err = new Error(`HTTP ${response.status}: ${response.statusText}`) const isRetryable = response.status === 502 || response.status === 503 diff --git a/neode-ui/src/composables/useControllerNav.ts b/neode-ui/src/composables/useControllerNav.ts index ec541d1d..41cd880c 100644 --- a/neode-ui/src/composables/useControllerNav.ts +++ b/neode-ui/src/composables/useControllerNav.ts @@ -436,6 +436,9 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { focusEl(retryContainers[0]) } else if (attempts >= 10) { clearInterval(poll) + // No containers on this page (e.g. Settings) — focus first focusable element + const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null + if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) } } }, 100) } @@ -475,16 +478,30 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { } if (dir === 'down') { - // Down from nav bar → first container + // Down from nav bar → jump to containers (remember tab for Up return) + rememberFocus('navBar', activeEl) const containers = getContainers() const nearest = findNearestInDirection(activeEl, containers, 'down') if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return } // Fallback: just focus first container - if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]) } + if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return } + // Containers not rendered yet — poll until they appear + let attempts = 0 + const poll = setInterval(() => { + attempts++ + const retryContainers = getContainers() + if (retryContainers[0]) { + clearInterval(poll) + rememberFocus('main', retryContainers[0]) + focusEl(retryContainers[0]) + } else if (attempts >= 10) { + clearInterval(poll) + } + }, 100) return } - // Up from nav bar → nothing + // Up from nav bar → nothing (use Escape to go to sidebar) return } @@ -500,15 +517,26 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { return } - // Up from top-row container → nav bar (if exists) + // Up from top-row container → nav bar, or previous focusable (linear pages like Settings) if (dir === 'up') { + const remembered = recallFocus('navBar') + if (remembered) { focusEl(remembered); return } const navItems = getNavBarItems() if (navItems.length) { const nearest = findNearestInDirection(activeEl, navItems, 'up') if (nearest) { focusEl(nearest); return } - // Fallback: first nav bar item const first = navItems[0] - if (first) focusEl(first) + if (first) { focusEl(first); return } + } + // No nav bar items — try any focusable element above (linear page nav) + const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null + if (zone) { + const allFocusable = getFocusableElements(zone).filter(el => + el.hasAttribute('data-controller-container') || + !el.closest('[data-controller-container]') + ) + const above = findNearestInDirection(activeEl, allFocusable, 'up') + if (above) { rememberFocus('main', above); focusEl(above) } } return } @@ -524,12 +552,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { return } - // At grid edges: try all focusable elements in main zone as fallback - // (prevents dead ends when spatial nav between containers fails) + // At grid edges: try containers + nav bar items as fallback + // (prevents dead ends, but never jumps into container inner controls) if (dir === 'down' || dir === 'right') { const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null if (zone) { - const allFocusable = getFocusableElements(zone) + const allFocusable = getFocusableElements(zone).filter(el => + el.hasAttribute('data-controller-container') || + !el.closest('[data-controller-container]') + ) const fallback = findNearestInDirection(activeEl, allFocusable, dir) if (fallback) { rememberFocus('main', fallback) @@ -550,7 +581,11 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { const target = activeTab ?? sidebar[0] if (target) { rememberFocus('main', activeEl); focusEl(target) } } else { - const all = getFocusableElements() + // Exclude container inner buttons to prevent focus getting lost + const all = getFocusableElements().filter(el => + el.hasAttribute('data-controller-container') || + !el.closest('[data-controller-container]') + ) const next = findNearestInDirection(activeEl, all, dir) if (next) focusEl(next) } @@ -607,6 +642,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) { watch(() => route.path, () => { zoneFocusMemory.delete('main') + zoneFocusMemory.delete('navBar') setTimeout(autoFocusMain, 150) }) diff --git a/neode-ui/src/stores/auth.ts b/neode-ui/src/stores/auth.ts index fd718b33..29d06583 100644 --- a/neode-ui/src/stores/auth.ts +++ b/neode-ui/src/stores/auth.ts @@ -32,7 +32,15 @@ export const useAuthStore = defineStore('auth', () => { // Initialize data structure immediately so dashboard can render await sync.initializeData() - // Connect WebSocket in background - don't block login flow + // Verify session cookies are established before WebSocket connect. + // Without this, the WS upgrade can race ahead of cookie processing → 401. + try { + await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } }) + } catch { + // Non-fatal: WS reconnect logic will handle it + } + + // Connect WebSocket in background sync.connectWebSocket().catch((err) => { if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err) }) @@ -52,6 +60,14 @@ export const useAuthStore = defineStore('auth', () => { const sync = useSyncStore() await sync.initializeData() + + // Verify session cookies are established before WebSocket connect + try { + await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } }) + } catch { + // Non-fatal: WS reconnect logic will handle it + } + sync.connectWebSocket().catch((err) => { if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err) }) diff --git a/neode-ui/src/stores/install.ts b/neode-ui/src/stores/install.ts new file mode 100644 index 00000000..94a88a3b --- /dev/null +++ b/neode-ui/src/stores/install.ts @@ -0,0 +1,82 @@ +// Install store — tracks in-progress app installations across navigation. +// Marketplace.vue writes here; Apps.vue reads to show "Installing..." cards. + +import { defineStore } from 'pinia' +import { reactive, computed } from 'vue' + +export interface InstallEntry { + id: string + title: string + status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error' + progress: number + message: string +} + +export const useInstallStore = defineStore('install', () => { + // Reactive map: appId -> InstallEntry + const entries = reactive(new Map()) + + /** All app IDs currently installing */ + const installingIds = computed(() => new Set(entries.keys())) + + /** Start tracking an install */ + function trackInstall(id: string, title: string) { + entries.set(id, { + id, + title, + status: 'downloading', + progress: 0, + message: 'Preparing installation...', + }) + } + + /** Update progress for an in-flight install */ + function updateProgress(id: string, update: Partial>) { + const current = entries.get(id) + if (!current) return + entries.set(id, { ...current, ...update }) + } + + /** Mark install complete and auto-clear after delay */ + function completeInstall(id: string) { + const current = entries.get(id) + if (!current) return + entries.set(id, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' }) + setTimeout(() => entries.delete(id), 2000) + } + + /** Mark install as failed and auto-clear after delay */ + function failInstall(id: string, message: string) { + const current = entries.get(id) + if (!current) return + entries.set(id, { ...current, status: 'error', progress: 0, message }) + setTimeout(() => entries.delete(id), 5000) + } + + /** Remove tracking (e.g. when backend reports the app is installed) */ + function clearInstall(id: string) { + entries.delete(id) + } + + /** Check if an app is currently installing */ + function isInstalling(id: string): boolean { + return entries.has(id) + } + + /** Get progress for an app, or undefined */ + function getProgress(id: string): InstallEntry | undefined { + return entries.get(id) + } + + return { + entries, + installingIds, + trackInstall, + updateProgress, + completeInstall, + failInstall, + clearInstall, + isInstalling, + getProgress, + } +}) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 36850cb5..5df04f80 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -53,7 +53,6 @@ *:focus-visible { outline: none; box-shadow: - 0 0 0 1px rgba(251, 146, 60, 0.4), 0 0 12px rgba(251, 146, 60, 0.2), 0 0 24px rgba(251, 146, 60, 0.08); transition: box-shadow 0.2s ease; @@ -68,7 +67,6 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { box-shadow: unset; - border-color: rgba(251, 146, 60, 0.4); outline: none; } @@ -124,18 +122,21 @@ input[type="radio"]:active + * { /* Containers: base scale for smooth grow animation */ [data-controller-container] { - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + outline: none !important; } -/* Containers: console-style focus — subtle lift + ambient glow through glass */ -[data-controller-container]:focus-visible { +/* Containers: console-style focus — lift + ambient orange glow. + Pure glow approach — no border-color or outline changes, avoids + Chromium compositor bugs with border-radius on translateZ(0) layers. */ +[data-controller-container]:focus-visible, +[data-controller-container]:focus { outline: none; - transform: scale(1.01) translateZ(0); + transform: translateY(-4px) scale(1.01) translateZ(0); box-shadow: - 0 0 0 1px rgba(251, 146, 60, 0.35), - 0 4px 20px rgba(251, 146, 60, 0.12), - 0 0 40px rgba(251, 146, 60, 0.06), - inset 0 1px 0 rgba(251, 146, 60, 0.1); + 0 0 6px 2px rgba(251, 146, 60, 0.35), + 0 0 20px rgba(251, 146, 60, 0.15), + 0 0 40px rgba(251, 146, 60, 0.08); } /* Global glassmorphism utilities */ @@ -2159,19 +2160,6 @@ html:has(body.video-background-active)::before { font-size: 0.8125rem; } -.discover-featured-card { - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease; - border-color: rgba(255, 255, 255, 0.12); -} - -.discover-featured-card:hover { - transform: translateY(-3px); - border-color: rgba(251, 146, 60, 0.25); - box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.6), - 0 0 40px rgba(251, 146, 60, 0.06); -} - .discover-installed-badge { display: inline-flex; align-items: center; @@ -2198,15 +2186,6 @@ html:has(body.video-background-active)::before { transform: translateY(-2px); } -.discover-app-card { - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; -} - -.discover-app-card:hover { - transform: translateY(-2px); - background: rgba(255, 255, 255, 0.06); -} - .discover-manifesto { border-color: rgba(251, 146, 60, 0.1); background: linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(251, 146, 60, 0.03) 100%); diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 6ea292ae..70f15f38 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -49,7 +49,7 @@
-
+
@@ -129,7 +129,7 @@
-
+
@@ -156,7 +156,7 @@
-
+

Network Interfaces

diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index a1cfaaa7..b839b535 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -15,6 +15,9 @@ export const SERVICE_NAMES = new Set([ 'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1', 'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1', 'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1', + // L484 web-only apps — parked in Services for now + 'botfights', 'nwnn', '484-kitchen', 'call-the-operator', + 'syntropy-institute', 't-zero', 'arch-presentation', ]) export function isServiceContainer(id: string): boolean { diff --git a/neode-ui/src/views/discover/AppGrid.vue b/neode-ui/src/views/discover/AppGrid.vue index d724029d..a12fbf3a 100644 --- a/neode-ui/src/views/discover/AppGrid.vue +++ b/neode-ui/src/views/discover/AppGrid.vue @@ -9,7 +9,7 @@ :data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined" tabindex="0" role="link" - class="discover-app-card glass-card p-5 cursor-pointer flex flex-col" + class="glass-card p-5 transition-all hover:-translate-y-1 cursor-pointer flex flex-col" :class="{ 'card-stagger': showStagger }" :style="{ '--stagger-index': index + staggerOffset }" @click="$emit('view-details', app)" diff --git a/neode-ui/src/views/discover/FeaturedApps.vue b/neode-ui/src/views/discover/FeaturedApps.vue index f262918d..66a8579c 100644 --- a/neode-ui/src/views/discover/FeaturedApps.vue +++ b/neode-ui/src/views/discover/FeaturedApps.vue @@ -13,7 +13,7 @@ data-controller-container tabindex="0" role="link" - class="discover-featured-card glass-card p-6 cursor-pointer" + class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer" :class="{ 'card-stagger': showStagger }" :style="{ '--stagger-index': index }" @click="$emit('view-details', app)" diff --git a/neode-ui/src/views/server/QuickActionsCard.vue b/neode-ui/src/views/server/QuickActionsCard.vue index 0a5277fa..89427feb 100644 --- a/neode-ui/src/views/server/QuickActionsCard.vue +++ b/neode-ui/src/views/server/QuickActionsCard.vue @@ -1,8 +1,8 @@