Container recovery: - Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s - Dependency-aware restarts: won't restart services before their deps - Reset dependent counters when a dependency recovers - Handle "created" state containers (were invisible to health monitor) - Added IndeedHub, mempool-api, mysql to tier system - Crash recovery: podman start timeout 30s→120s with retry - Podman client: socket timeout 5s→30s, added restart policy UI state representation: - Exit code 0 shows "stopped" (gray), not "crashed" (red) - Exit code 137 shows "killed (OOM)" - Non-zero exit shows "crashed" (red) - Added exit_code field to PackageDataEntry Install/uninstall fixes: - Install returns error when container doesn't start (was silent success) - Post-install hooks awaited instead of fire-and-forget tokio::spawn - Uninstall: graceful rm before force, volume prune, network cleanup - Uninstall returns error on partial failure (was 200 OK) Config consistency: - DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded) - Bitcoin: added ZMQ ports 28332/28333 for LND block notifications - IndeedHub port 7777→8190 (was conflicting with strfry) - Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0 Performance: - Metrics collector interval 60s→300s (was duplicating health monitor) - Podman client: proper error propagation instead of unwrap_or_default Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
282 lines
10 KiB
Vue
282 lines
10 KiB
Vue
<template>
|
|
<div class="min-h-screen flex relative dashboard-view" :class="{ 'glass-throw-active': showZoomIn }">
|
|
<!-- Skip to main content link for keyboard users -->
|
|
<!-- Skip-to-content handled by controller nav sidebar→main transition -->
|
|
<!-- Background container with 3D perspective - full width to avoid letterboxing -->
|
|
<div class="bg-perspective-container">
|
|
<!-- Background - primary layer (visible for all routes, transitions out only for detail pages) -->
|
|
<div
|
|
ref="bgDefault"
|
|
class="bg-layer bg-fullwidth"
|
|
:class="[
|
|
{ 'bg-transitioning-out': showAltBackground },
|
|
{ 'zoom-reveal-bg': showZoomIn }
|
|
]"
|
|
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
|
/>
|
|
<!-- Background - detail layer (only visible during app/marketplace detail 3D transition) -->
|
|
<div
|
|
ref="bgAlt"
|
|
class="bg-layer bg-fullwidth"
|
|
:class="{ 'bg-transitioning-in': showAltBackground }"
|
|
style="background-image: url(/assets/img/bg-intro-3.jpg)"
|
|
/>
|
|
<!-- Glitch overlays - trigger on background change -->
|
|
<div
|
|
class="bg-glitch-layer-1"
|
|
:class="{ 'glitch-active': isGlitching }"
|
|
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
|
/>
|
|
<div
|
|
class="bg-glitch-layer-2"
|
|
:class="{ 'glitch-active': isGlitching }"
|
|
:style="{ backgroundImage: `url(/assets/img/${backgroundImage})` }"
|
|
/>
|
|
<div
|
|
class="bg-glitch-scan"
|
|
:class="{ 'glitch-active': isGlitching }"
|
|
/>
|
|
<!-- Glitch overlays removed — only intro glitch plays (via isGlitching) -->
|
|
</div>
|
|
|
|
<!-- Oomph accent - brief impact flash when dashboard loads -->
|
|
<div
|
|
v-if="showZoomIn"
|
|
class="fixed inset-0 pointer-events-none z-[100] oomph-flash"
|
|
aria-hidden="true"
|
|
/>
|
|
<!-- Reveal flashes and glitch overlay - enthralling entrance -->
|
|
<div
|
|
v-if="showZoomIn"
|
|
class="fixed inset-0 pointer-events-none z-[99] reveal-flash-glitch"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<!-- Background overlay - uniform 0.2 opacity -->
|
|
<div
|
|
class="fixed inset-0 pointer-events-none bg-black/20"
|
|
style="z-index: -5;"
|
|
/>
|
|
|
|
<!-- Sidebar - Desktop Only -->
|
|
<DashboardSidebar :show-zoom-in="showZoomIn" @logout="handleLogout" />
|
|
|
|
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
|
<main
|
|
id="main-content"
|
|
data-controller-zone="main"
|
|
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
|
:class="{ 'glass-throw-main': showZoomIn }"
|
|
>
|
|
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
|
<!-- Controller zone entry point - no switcher -->
|
|
</div>
|
|
|
|
<!-- Connection Status Banners -->
|
|
<ConnectionBanner />
|
|
|
|
<div class="perspective-container-wrapper glass-piece" :class="{ 'glass-throw-content': showZoomIn && !isHomeRoute }">
|
|
<div class="perspective-container">
|
|
<RouterView v-slot="{ Component, route }">
|
|
<Transition :name="getTransitionName(route)">
|
|
<div :key="route.path" class="view-wrapper">
|
|
<div
|
|
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
|
:class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
|
|
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
|
class="mobile-safe-top"
|
|
>
|
|
<component :is="Component" />
|
|
</div>
|
|
<div
|
|
v-else
|
|
:class="[
|
|
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top',
|
|
needsMobileBackButtonSpace
|
|
? 'mobile-scroll-pad-back'
|
|
: 'mobile-scroll-pad'
|
|
]"
|
|
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
|
|
>
|
|
<component :is="Component" class="view-container flex-none" />
|
|
<!-- Bottom spacer — scroll clearance on all pages -->
|
|
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</RouterView>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel mode app session — renders alongside current page content -->
|
|
<Transition name="panel-slide">
|
|
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
|
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
|
|
</div>
|
|
</Transition>
|
|
</main>
|
|
|
|
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
|
<DashboardMobileNav ref="mobileNavRef" :show-zoom-in="showZoomIn" />
|
|
|
|
<!-- Health Notifications Toast -->
|
|
<HealthNotifications />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import { RouterView, useRouter, useRoute } from 'vue-router'
|
|
import { useAppStore } from '../stores/app'
|
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
|
import AppSession from '@/views/AppSession.vue'
|
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
|
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
|
|
|
import DashboardSidebar from '@/views/dashboard/DashboardSidebar.vue'
|
|
import DashboardMobileNav from '@/views/dashboard/DashboardMobileNav.vue'
|
|
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
|
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
|
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
|
import '@/views/dashboard/dashboard-styles.css'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const store = useAppStore()
|
|
const appLauncher = useAppLauncherStore()
|
|
const loginTransition = useLoginTransitionStore()
|
|
const { getTransitionName } = useRouteTransitions()
|
|
|
|
const showZoomIn = ref(false)
|
|
const pendingTimers: ReturnType<typeof setTimeout>[] = []
|
|
|
|
function scheduledTimeout(fn: () => void, ms: number) {
|
|
const id = setTimeout(fn, ms)
|
|
pendingTimers.push(id)
|
|
return id
|
|
}
|
|
|
|
const showAltBackground = ref(false)
|
|
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
|
|
const isGlitching = ref(false)
|
|
|
|
const backgroundImage = computed(() => {
|
|
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
|
return ROUTE_BACKGROUNDS[route.path] || 'bg-home.jpg'
|
|
})
|
|
|
|
const isDarkRoute = computed(() => {
|
|
const p = route.path
|
|
return p.includes('/dashboard/web5') ||
|
|
p.includes('/dashboard/server') ||
|
|
p.includes('/dashboard/settings') ||
|
|
(p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
|
|
p.includes('/dashboard/marketplace') ||
|
|
p.includes('/dashboard/discover') ||
|
|
p.includes('/dashboard/cloud')
|
|
})
|
|
|
|
const showDarkOverlay = ref(isDarkRoute.value)
|
|
let overlayTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
watch(isDarkRoute, (dark) => {
|
|
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
|
if (dark) {
|
|
showDarkOverlay.value = true
|
|
} else {
|
|
overlayTimer = scheduledTimeout(() => { showDarkOverlay.value = false }, 450)
|
|
}
|
|
})
|
|
|
|
const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path))
|
|
|
|
const mobileNavRef = ref<InstanceType<typeof DashboardMobileNav> | null>(null)
|
|
|
|
const mobileTabPaddingTop = computed(() => {
|
|
return mobileNavRef.value?.mobileTabPaddingTop ?? 0
|
|
})
|
|
|
|
watch(() => route.path, (newPath) => {
|
|
const isAppDetails = isDetailRoute(newPath)
|
|
const wasAppDetails = showAltBackground.value
|
|
|
|
showAltBackground.value = isAppDetails
|
|
|
|
if (isAppDetails && !wasAppDetails) {
|
|
scheduledTimeout(() => {
|
|
isGlitching.value = true
|
|
scheduledTimeout(() => { isGlitching.value = false }, 375)
|
|
}, 500)
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
document.body.classList.add('dashboard-active')
|
|
if (loginTransition.justLoggedIn) {
|
|
playDashboardLoadOomph()
|
|
showZoomIn.value = true
|
|
loginTransition.setPendingWelcomeTyping(true)
|
|
loginTransition.setJustLoggedIn(false)
|
|
const triggerRevealGlitch = () => {
|
|
isGlitching.value = true
|
|
scheduledTimeout(() => { isGlitching.value = false }, 380)
|
|
}
|
|
scheduledTimeout(triggerRevealGlitch, 500)
|
|
scheduledTimeout(triggerRevealGlitch, 1200)
|
|
scheduledTimeout(triggerRevealGlitch, 2000)
|
|
scheduledTimeout(() => { showZoomIn.value = false }, 8000)
|
|
scheduledTimeout(() => {
|
|
loginTransition.setStartWelcomeTyping(true)
|
|
loginTransition.setPendingWelcomeTyping(false)
|
|
}, 4000)
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKioskShortcuts)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.body.classList.remove('dashboard-active')
|
|
window.removeEventListener('keydown', handleKioskShortcuts)
|
|
for (const id of pendingTimers) clearTimeout(id)
|
|
pendingTimers.length = 0
|
|
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
|
})
|
|
|
|
function isKioskMode(): boolean {
|
|
try {
|
|
return localStorage.getItem('kiosk') === 'true' || new URLSearchParams(window.location.search).has('kiosk')
|
|
} catch { return false }
|
|
}
|
|
|
|
function handleKioskShortcuts(e: KeyboardEvent) {
|
|
if (!isKioskMode()) return
|
|
if (e.ctrlKey && e.shiftKey) {
|
|
if (e.key === 'R' || e.key === 'r') {
|
|
e.preventDefault()
|
|
router.push('/recovery')
|
|
} else if (e.key === 'H' || e.key === 'h') {
|
|
e.preventDefault()
|
|
router.push('/dashboard')
|
|
} else if (e.key === 'Q' || e.key === 'q') {
|
|
e.preventDefault()
|
|
if (confirm('Reboot the server?')) {
|
|
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await store.logout()
|
|
} catch {
|
|
/* proceed to login regardless */
|
|
}
|
|
router.push('/login').catch(() => {
|
|
window.location.href = '/login'
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<!-- Styles extracted to dashboard/dashboard-styles.css -->
|