// Main application store using Pinia import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { DataModel } from '../types/api' import { wsClient, applyDataPatch } from '../api/websocket' import { rpcClient } from '../api/rpc-client' export const useAppStore = defineStore('app', () => { // State const data = ref(null) const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true') const isConnected = ref(false) const isReconnecting = ref(false) const isLoading = ref(false) const error = ref(null) let isWsSubscribed = false let sessionValidated = false // Computed const serverInfo = computed(() => data.value?.['server-info']) const packages = computed(() => data.value?.['package-data'] || {}) const peerHealth = computed>(() => data.value?.['peer-health'] || {}) const uiData = computed(() => data.value?.ui) const serverName = computed(() => serverInfo.value?.name || 'Archipelago') const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false) const isShuttingDown = computed(() => serverInfo.value?.['status-info']?.['shutting-down'] || false) const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value) // Actions async function login(password: string): Promise<{ requires_totp?: boolean }> { isLoading.value = true error.value = null try { const result = await rpcClient.login(password) if (result && result.requires_totp) { return { requires_totp: true } } isAuthenticated.value = true sessionValidated = true localStorage.setItem('neode-auth', 'true') // Initialize data structure immediately so dashboard can render await initializeData() // Connect WebSocket in background - don't block login flow connectWebSocket().catch((err) => { if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err) }) return {} } catch (err) { error.value = err instanceof Error ? err.message : 'Login failed' throw err } finally { isLoading.value = false } } async function completeLoginAfterTotp(): Promise { isAuthenticated.value = true sessionValidated = true localStorage.setItem('neode-auth', 'true') await initializeData() connectWebSocket().catch((err) => { if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err) }) } async function logout(): Promise { try { await rpcClient.logout() } catch (err) { if (import.meta.env.DEV) console.error('Logout error:', err) } finally { isAuthenticated.value = false sessionValidated = false localStorage.removeItem('neode-auth') data.value = null isWsSubscribed = false wsClient.disconnect() isConnected.value = false isReconnecting.value = false } } async function connectWebSocket(): Promise { try { if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...') isReconnecting.value = true // Don't create multiple subscriptions - check if already subscribed if (!isWsSubscribed) { // Subscribe to updates BEFORE connecting (so we catch initial data) isWsSubscribed = true // Listen for connection state changes wsClient.onConnectionStateChange((state) => { if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state) isConnected.value = state === 'connected' isReconnecting.value = state === 'connecting' }) wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => { // Handle mock backend format: {type: 'initial', data: {...}} if (update?.type === 'initial' && update?.data) { if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend') data.value = update.data isConnected.value = true isReconnecting.value = false } // Handle real backend format: {rev: 0, data: {...}} else if (update?.data && update?.rev !== undefined) { data.value = update.data isConnected.value = true isReconnecting.value = false } // Handle patch updates (both backends) else if (data.value && update?.patch) { try { if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown') data.value = applyDataPatch(data.value, update.patch) // Mark as connected once we receive any valid patch if (!isConnected.value) { isConnected.value = true isReconnecting.value = false } } catch (err) { if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err) } } }) } // Now connect (or reconnect if already connected) // Only attempt to connect if not already connected if (wsClient.isConnected()) { if (import.meta.env.DEV) console.log('[Store] WebSocket already connected') isConnected.value = true isReconnecting.value = false return } await wsClient.connect() if (import.meta.env.DEV) console.log('[Store] WebSocket connected') // Connection state will be updated via the callback if (wsClient.isConnected()) { isConnected.value = true isReconnecting.value = false } } catch (err) { if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err) // Don't mark as disconnected immediately - let reconnection logic handle it // The WebSocket client will retry automatically isReconnecting.value = true isConnected.value = false // Don't throw - allow app to work without real-time updates // The WebSocket will reconnect in the background } } async function initializeData(): Promise { // Initialize with empty data structure // The WebSocket will populate it with real data data.value = { 'server-info': { id: '', version: '', name: null, pubkey: '', 'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null, }, 'lan-address': null, 'tor-address': null, unread: 0, 'wifi-ssids': [], 'zram-enabled': false, }, 'package-data': {}, ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {}, }, theme: 'dark', }, } } // Check session validity on app load or stale auth async function checkSession(): Promise { if (!localStorage.getItem('neode-auth')) { return false } try { await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } }) isAuthenticated.value = true sessionValidated = true await initializeData() connectWebSocket().catch((err) => { if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err) isReconnecting.value = true }) return true } catch (err) { if (import.meta.env.DEV) console.error('[Store] Session check failed:', err) localStorage.removeItem('neode-auth') isAuthenticated.value = false sessionValidated = false isWsSubscribed = false isConnected.value = false isReconnecting.value = false wsClient.disconnect() return false } } function needsSessionValidation(): boolean { return isAuthenticated.value && !sessionValidated } // Package actions async function installPackage(id: string, marketplaceUrl: string, version: string): Promise { return rpcClient.installPackage(id, marketplaceUrl, version) } async function uninstallPackage(id: string): Promise { return rpcClient.uninstallPackage(id) } async function startPackage(id: string): Promise { return rpcClient.startPackage(id) } async function stopPackage(id: string): Promise { return rpcClient.stopPackage(id) } async function restartPackage(id: string): Promise { return rpcClient.restartPackage(id) } // Server actions async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> { return rpcClient.updateServer(marketplaceUrl) } async function restartServer(): Promise { return rpcClient.restartServer() } async function shutdownServer(): Promise { return rpcClient.shutdownServer() } async function getMetrics(): Promise> { return rpcClient.getMetrics() } // Marketplace actions async function getMarketplace(url: string): Promise> { return rpcClient.getMarketplace(url) } function updateServerName(name: string) { if (data.value?.['server-info']) { data.value['server-info'].name = name } } return { // State data, isAuthenticated, isConnected, isReconnecting, isLoading, error, // Computed serverInfo, packages, peerHealth, uiData, serverName, isRestarting, isShuttingDown, isOffline, // Actions login, completeLoginAfterTotp, logout, checkSession, needsSessionValidation, connectWebSocket, installPackage, uninstallPackage, startPackage, stopPackage, restartPackage, updateServer, restartServer, shutdownServer, getMetrics, getMarketplace, updateServerName, } })