fix: auth, container resilience, ISO build, gamepad polish
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
68f2a9c5bf
commit
fdd69ce1b5
@ -220,7 +220,8 @@ impl RpcHandler {
|
|||||||
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
// 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).
|
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
||||||
let csrf_exempt = matches!(rpc_req.method.as_str(),
|
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"
|
| "tor.onion-addresses" | "federation.list-nodes" | "system.get-settings"
|
||||||
| "system.get-node-key" | "system.get-metrics" | "system.get-version"
|
| "system.get-node-key" | "system.get-metrics" | "system.get-version"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -326,13 +326,9 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
|||||||
let containers: Vec<serde_json::Value> =
|
let containers: Vec<serde_json::Value> =
|
||||||
serde_json::from_str(&stdout).unwrap_or_default();
|
serde_json::from_str(&stdout).unwrap_or_default();
|
||||||
|
|
||||||
// Backend services and one-shot init containers to skip
|
// Monitor ALL long-running containers for health — backend services (databases,
|
||||||
let skip = [
|
// nbxplorer, mempool-api) and UI containers need auto-restart too.
|
||||||
"btcpay-db", "nbxplorer", "mempool-db", "mempool-api",
|
// Only skip ephemeral containers (build infrastructure, init one-shots).
|
||||||
"penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey",
|
|
||||||
"penpot-mailcatch", "immich_postgres", "immich_redis",
|
|
||||||
"endurain-db", "nextcloud-db",
|
|
||||||
];
|
|
||||||
|
|
||||||
containers
|
containers
|
||||||
.iter()
|
.iter()
|
||||||
@ -345,20 +341,16 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
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
|
// Skip podman-compose infrastructure and one-shot init containers
|
||||||
if name.starts_with("indeedhub-build_") || name.contains("-init") {
|
if name.starts_with("indeedhub-build_") || name.contains("-init") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let app_id = name
|
||||||
|
.strip_prefix("archy-")
|
||||||
|
.unwrap_or(&name)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let state = c.get("State")
|
let state = c.get("State")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
|
|||||||
@ -359,28 +359,31 @@ mod health_monitor_logic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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![
|
let containers = vec![
|
||||||
("bitcoin-knots", "exited"),
|
("bitcoin-knots", "exited"),
|
||||||
("archy-bitcoin-ui", "exited"),
|
("archy-bitcoin-ui", "exited"),
|
||||||
("archy-lnd-ui", "exited"),
|
("archy-lnd-ui", "exited"),
|
||||||
("grafana", "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
|
let to_check: Vec<&str> = containers
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(name, _)| {
|
.filter(|(name, _)| {
|
||||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
!name.starts_with("indeedhub-build_") && !name.contains("-init")
|
||||||
!skip_suffixes.iter().any(|s| id.ends_with(s))
|
|
||||||
&& !skip_backends.contains(&id)
|
|
||||||
})
|
})
|
||||||
.map(|(name, _)| *name)
|
.map(|(name, _)| *name)
|
||||||
.collect();
|
.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]
|
#[test]
|
||||||
|
|||||||
@ -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 archipelago-tor-helper.path /etc/systemd/system/archipelago-tor-helper.path
|
||||||
|
|
||||||
# Copy container doctor + reconcile scripts (referenced by the services above)
|
# 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 container-doctor.sh /home/archipelago/archy/scripts/container-doctor.sh
|
||||||
COPY reconcile-containers.sh /home/archipelago/archy/scripts/reconcile-containers.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
|
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
|
chown -R archipelago:archipelago /home/archipelago/archy
|
||||||
|
|
||||||
# Enable services
|
# 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.service" "$WORK_DIR/archipelago-reconcile.service"
|
||||||
cp "$SCRIPT_DIR/configs/archipelago-reconcile.timer" "$WORK_DIR/archipelago-reconcile.timer"
|
cp "$SCRIPT_DIR/configs/archipelago-reconcile.timer" "$WORK_DIR/archipelago-reconcile.timer"
|
||||||
# Copy the actual scripts the services reference
|
# 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
|
if [ -f "$SCRIPT_DIR/../scripts/$s" ]; then
|
||||||
cp "$SCRIPT_DIR/../scripts/$s" "$WORK_DIR/$s"
|
cp "$SCRIPT_DIR/../scripts/$s" "$WORK_DIR/$s"
|
||||||
fi
|
fi
|
||||||
done
|
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/"
|
echo " Using container doctor + reconcile timers from configs/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -78,11 +78,22 @@ class RPCClient {
|
|||||||
}
|
}
|
||||||
throw new Error('Session expired')
|
throw new Error('Session expired')
|
||||||
}
|
}
|
||||||
// CSRF 403: retry twice after delay (cookie may have been
|
// 403: read body to distinguish CSRF (retryable) from RBAC (permanent)
|
||||||
// updated by a concurrent Set-Cookie response not yet visible to JS)
|
if (response.status === 403) {
|
||||||
if (response.status === 403 && attempt < maxRetries - 1) {
|
let reason = ''
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
try {
|
||||||
continue
|
const body: RPCResponse<unknown> = 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 err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
const isRetryable = response.status === 502 || response.status === 503
|
const isRetryable = response.status === 502 || response.status === 503
|
||||||
|
|||||||
@ -436,6 +436,9 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
focusEl(retryContainers[0])
|
focusEl(retryContainers[0])
|
||||||
} else if (attempts >= 10) {
|
} else if (attempts >= 10) {
|
||||||
clearInterval(poll)
|
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)
|
}, 100)
|
||||||
}
|
}
|
||||||
@ -475,16 +478,30 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dir === 'down') {
|
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 containers = getContainers()
|
||||||
const nearest = findNearestInDirection(activeEl, containers, 'down')
|
const nearest = findNearestInDirection(activeEl, containers, 'down')
|
||||||
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
|
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
|
||||||
// Fallback: just focus first container
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up from nav bar → nothing
|
// Up from nav bar → nothing (use Escape to go to sidebar)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,15 +517,26 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
return
|
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') {
|
if (dir === 'up') {
|
||||||
|
const remembered = recallFocus('navBar')
|
||||||
|
if (remembered) { focusEl(remembered); return }
|
||||||
const navItems = getNavBarItems()
|
const navItems = getNavBarItems()
|
||||||
if (navItems.length) {
|
if (navItems.length) {
|
||||||
const nearest = findNearestInDirection(activeEl, navItems, 'up')
|
const nearest = findNearestInDirection(activeEl, navItems, 'up')
|
||||||
if (nearest) { focusEl(nearest); return }
|
if (nearest) { focusEl(nearest); return }
|
||||||
// Fallback: first nav bar item
|
|
||||||
const first = navItems[0]
|
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
|
return
|
||||||
}
|
}
|
||||||
@ -524,12 +552,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// At grid edges: try all focusable elements in main zone as fallback
|
// At grid edges: try containers + nav bar items as fallback
|
||||||
// (prevents dead ends when spatial nav between containers fails)
|
// (prevents dead ends, but never jumps into container inner controls)
|
||||||
if (dir === 'down' || dir === 'right') {
|
if (dir === 'down' || dir === 'right') {
|
||||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||||
if (zone) {
|
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)
|
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
rememberFocus('main', fallback)
|
rememberFocus('main', fallback)
|
||||||
@ -550,7 +581,11 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
const target = activeTab ?? sidebar[0]
|
const target = activeTab ?? sidebar[0]
|
||||||
if (target) { rememberFocus('main', activeEl); focusEl(target) }
|
if (target) { rememberFocus('main', activeEl); focusEl(target) }
|
||||||
} else {
|
} 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)
|
const next = findNearestInDirection(activeEl, all, dir)
|
||||||
if (next) focusEl(next)
|
if (next) focusEl(next)
|
||||||
}
|
}
|
||||||
@ -607,6 +642,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
|
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
zoneFocusMemory.delete('main')
|
zoneFocusMemory.delete('main')
|
||||||
|
zoneFocusMemory.delete('navBar')
|
||||||
setTimeout(autoFocusMain, 150)
|
setTimeout(autoFocusMain, 150)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// Initialize data structure immediately so dashboard can render
|
// Initialize data structure immediately so dashboard can render
|
||||||
await sync.initializeData()
|
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) => {
|
sync.connectWebSocket().catch((err) => {
|
||||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', 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()
|
const sync = useSyncStore()
|
||||||
await sync.initializeData()
|
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) => {
|
sync.connectWebSocket().catch((err) => {
|
||||||
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
|
||||||
})
|
})
|
||||||
|
|||||||
82
neode-ui/src/stores/install.ts
Normal file
82
neode-ui/src/stores/install.ts
Normal file
@ -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<string, InstallEntry>())
|
||||||
|
|
||||||
|
/** 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<Omit<InstallEntry, 'id'>>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -53,7 +53,6 @@
|
|||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(251, 146, 60, 0.4),
|
|
||||||
0 0 12px rgba(251, 146, 60, 0.2),
|
0 0 12px rgba(251, 146, 60, 0.2),
|
||||||
0 0 24px rgba(251, 146, 60, 0.08);
|
0 0 24px rgba(251, 146, 60, 0.08);
|
||||||
transition: box-shadow 0.2s ease;
|
transition: box-shadow 0.2s ease;
|
||||||
@ -68,7 +67,6 @@ input:focus-visible,
|
|||||||
textarea:focus-visible,
|
textarea:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible {
|
||||||
box-shadow: unset;
|
box-shadow: unset;
|
||||||
border-color: rgba(251, 146, 60, 0.4);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,18 +122,21 @@ input[type="radio"]:active + * {
|
|||||||
|
|
||||||
/* Containers: base scale for smooth grow animation */
|
/* Containers: base scale for smooth grow animation */
|
||||||
[data-controller-container] {
|
[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 */
|
/* Containers: console-style focus — lift + ambient orange glow.
|
||||||
[data-controller-container]:focus-visible {
|
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;
|
outline: none;
|
||||||
transform: scale(1.01) translateZ(0);
|
transform: translateY(-4px) scale(1.01) translateZ(0);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(251, 146, 60, 0.35),
|
0 0 6px 2px rgba(251, 146, 60, 0.35),
|
||||||
0 4px 20px rgba(251, 146, 60, 0.12),
|
0 0 20px rgba(251, 146, 60, 0.15),
|
||||||
0 0 40px rgba(251, 146, 60, 0.06),
|
0 0 40px rgba(251, 146, 60, 0.08);
|
||||||
inset 0 1px 0 rgba(251, 146, 60, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global glassmorphism utilities */
|
/* Global glassmorphism utilities */
|
||||||
@ -2159,19 +2160,6 @@ html:has(body.video-background-active)::before {
|
|||||||
font-size: 0.8125rem;
|
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 {
|
.discover-installed-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2198,15 +2186,6 @@ html:has(body.video-background-active)::before {
|
|||||||
transform: translateY(-2px);
|
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 {
|
.discover-manifesto {
|
||||||
border-color: rgba(251, 146, 60, 0.1);
|
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%);
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(251, 146, 60, 0.03) 100%);
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
<!-- Overview Cards -->
|
<!-- Overview Cards -->
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Local Network Card -->
|
<!-- Local Network Card -->
|
||||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
|
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -129,7 +129,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Web3 Card -->
|
<!-- Web3 Card -->
|
||||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
|
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||||
@ -156,7 +156,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
|
||||||
<!-- Network Interfaces -->
|
<!-- Network Interfaces -->
|
||||||
<div class="glass-card p-6">
|
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||||
|
|||||||
@ -15,6 +15,9 @@ export const SERVICE_NAMES = new Set([
|
|||||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
'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_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_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 {
|
export function isServiceContainer(id: string): boolean {
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="link"
|
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 }"
|
:class="{ 'card-stagger': showStagger }"
|
||||||
:style="{ '--stagger-index': index + staggerOffset }"
|
:style="{ '--stagger-index': index + staggerOffset }"
|
||||||
@click="$emit('view-details', app)"
|
@click="$emit('view-details', app)"
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
data-controller-container
|
data-controller-container
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="link"
|
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 }"
|
:class="{ 'card-stagger': showStagger }"
|
||||||
:style="{ '--stagger-index': index }"
|
:style="{ '--stagger-index': index }"
|
||||||
@click="$emit('view-details', app)"
|
@click="$emit('view-details', app)"
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass-card p-6 mb-6">
|
<div data-controller-container tabindex="0" class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<!-- Service Status -->
|
<!-- Service Status -->
|
||||||
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||||
@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tor Status -->
|
<!-- Tor Status -->
|
||||||
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
|
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
|
||||||
@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-Sync Toggle -->
|
<!-- Auto-Sync Toggle -->
|
||||||
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center justify-between min-w-0">
|
<div class="flex items-center justify-between min-w-0">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -60,7 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs & Diagnostics -->
|
<!-- Logs & Diagnostics -->
|
||||||
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass-card p-6">
|
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
|
|||||||
@ -76,7 +76,7 @@ function closeChangePasswordModal() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Change Password -->
|
<!-- Change Password -->
|
||||||
<div data-controller-container tabindex="0" class="mb-6">
|
<div class="mb-6">
|
||||||
<button
|
<button
|
||||||
ref="changePasswordRestoreFocusRef"
|
ref="changePasswordRestoreFocusRef"
|
||||||
@click="showChangePasswordModal = true"
|
@click="showChangePasswordModal = true"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user