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 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"
|
||||
);
|
||||
|
||||
@ -326,13 +326,9 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
let containers: Vec<serde_json::Value> =
|
||||
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<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
|
||||
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")
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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<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 isRetryable = response.status === 502 || response.status === 503
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
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 {
|
||||
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%);
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
|
||||
<!-- 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-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">
|
||||
@ -129,7 +129,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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-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>
|
||||
@ -156,7 +156,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
|
||||
<!-- 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>
|
||||
<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-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 {
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<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">
|
||||
<!-- 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="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
@ -23,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
|
||||
@ -44,7 +44,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 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">
|
||||
@ -60,7 +60,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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" />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<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 gap-3">
|
||||
<div class="relative shrink-0">
|
||||
|
||||
@ -76,7 +76,7 @@ function closeChangePasswordModal() {
|
||||
|
||||
<template>
|
||||
<!-- Change Password -->
|
||||
<div data-controller-container tabindex="0" class="mb-6">
|
||||
<div class="mb-6">
|
||||
<button
|
||||
ref="changePasswordRestoreFocusRef"
|
||||
@click="showChangePasswordModal = true"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user