bug fixing and deploy and build diagnostics

This commit is contained in:
Dorian
2026-03-22 03:30:21 +00:00
parent 01942cea95
commit 13e4a738be
198 changed files with 21703 additions and 19587 deletions

View File

@@ -1,342 +1,69 @@
// Main application store using Pinia
// Facade store — re-exports auth, sync, and server stores for backward compatibility.
// All 29+ files that import useAppStore() continue to work without changes.
// Uses defineStore with computed/writableComputed to preserve full reactivity.
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'
import { defineStore, storeToRefs } from 'pinia'
import { useAuthStore } from './auth'
import { useSyncStore } from './sync'
import { useServerStore } from './server'
export const useAppStore = defineStore('app', () => {
// State
const data = ref<DataModel | null>(null)
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
const isConnected = ref(false)
const isReconnecting = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
let isWsSubscribed = false
let isWsConnecting = false
let sessionValidated = false
const auth = useAuthStore()
const sync = useSyncStore()
const server = useServerStore()
// Computed
const serverInfo = computed(() => data.value?.['server-info'])
const packages = computed(() => data.value?.['package-data'] || {})
const peerHealth = computed<Record<string, boolean>>(() => 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)
// Writable refs — delegate reads and writes to the sub-stores
const { isAuthenticated, isLoading, error } = storeToRefs(auth)
const { data, isConnected, isReconnecting } = storeToRefs(sync)
// 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
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
// 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<void> {
isAuthenticated.value = true
sessionValidated = true
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
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<void> {
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<void> {
// Prevent concurrent connection attempts
if (isWsConnecting) return
isWsConnecting = true
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')
// Fetch fresh state after reconnect to avoid stale patch application
try {
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
if (freshState?.data) {
data.value = freshState.data
}
} catch {
// Non-fatal: WebSocket patches will still work
if (import.meta.env.DEV) console.warn('[Store] Failed to fetch fresh state after reconnect')
}
// 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
} finally {
isWsConnecting = false
}
}
async function initializeData(): Promise<void> {
// 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<boolean> {
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<string> {
return rpcClient.installPackage(id, marketplaceUrl, version)
}
async function uninstallPackage(id: string): Promise<void> {
return rpcClient.uninstallPackage(id)
}
async function startPackage(id: string): Promise<void> {
return rpcClient.startPackage(id)
}
async function stopPackage(id: string): Promise<void> {
return rpcClient.stopPackage(id)
}
async function restartPackage(id: string): Promise<void> {
return rpcClient.restartPackage(id)
}
// Server actions
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
return rpcClient.updateServer(marketplaceUrl)
}
async function restartServer(): Promise<void> {
return rpcClient.restartServer()
}
async function shutdownServer(): Promise<void> {
return rpcClient.shutdownServer()
}
async function getMetrics(): Promise<Record<string, unknown>> {
return rpcClient.getMetrics()
}
// Marketplace actions
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
return rpcClient.getMarketplace(url)
}
function updateServerName(name: string) {
if (data.value?.['server-info']) {
data.value['server-info'].name = name
}
}
// Read-only computed — delegate to sub-stores
const { serverInfo, packages, peerHealth, uiData } = storeToRefs(sync)
const { serverName, isRestarting, isShuttingDown, isOffline } = storeToRefs(server)
return {
// State
data,
// Auth state (writable refs)
isAuthenticated,
isConnected,
isReconnecting,
isLoading,
error,
// Computed
// Sync state (writable refs)
data,
isConnected,
isReconnecting,
// Sync computed (read-only)
serverInfo,
packages,
peerHealth,
uiData,
// Server computed (read-only)
serverName,
isRestarting,
isShuttingDown,
isOffline,
// Actions
login,
completeLoginAfterTotp,
logout,
checkSession,
needsSessionValidation,
connectWebSocket,
installPackage,
uninstallPackage,
startPackage,
stopPackage,
restartPackage,
updateServer,
restartServer,
shutdownServer,
getMetrics,
getMarketplace,
updateServerName,
// Auth actions
login: auth.login,
completeLoginAfterTotp: auth.completeLoginAfterTotp,
logout: auth.logout,
checkSession: auth.checkSession,
needsSessionValidation: auth.needsSessionValidation,
// Sync actions
connectWebSocket: sync.connectWebSocket,
// Server actions
installPackage: server.installPackage,
uninstallPackage: server.uninstallPackage,
startPackage: server.startPackage,
stopPackage: server.stopPackage,
restartPackage: server.restartPackage,
updateServer: server.updateServer,
restartServer: server.restartServer,
shutdownServer: server.shutdownServer,
getMetrics: server.getMetrics,
getMarketplace: server.getMarketplace,
updateServerName: server.updateServerName,
}
})

121
neode-ui/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,121 @@
// Authentication store — login, logout, session management
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { rpcClient } from '../api/rpc-client'
import { useSyncStore } from './sync'
export const useAuthStore = defineStore('auth', () => {
// State
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
const isLoading = ref(false)
const error = ref<string | null>(null)
let sessionValidated = false
// 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
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
const sync = useSyncStore()
// Initialize data structure immediately so dashboard can render
await sync.initializeData()
// Connect WebSocket in background - don't block login flow
sync.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<void> {
isAuthenticated.value = true
sessionValidated = true
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
const sync = useSyncStore()
await sync.initializeData()
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
})
}
async function logout(): Promise<void> {
const sync = useSyncStore()
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')
sync.resetOnLogout()
}
}
async function checkSession(): Promise<boolean> {
if (!localStorage.getItem('neode-auth')) {
return false
}
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
isAuthenticated.value = true
sessionValidated = true
const sync = useSyncStore()
await sync.initializeData()
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
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
const sync = useSyncStore()
sync.resetOnLogout()
return false
}
}
function needsSessionValidation(): boolean {
return isAuthenticated.value && !sessionValidated
}
return {
// State
isAuthenticated,
isLoading,
error,
// Actions
login,
completeLoginAfterTotp,
logout,
checkSession,
needsSessionValidation,
}
})

View File

@@ -0,0 +1,86 @@
// Server store — computed server state and RPC action proxies
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { rpcClient } from '../api/rpc-client'
import { useSyncStore } from './sync'
export const useServerStore = defineStore('server', () => {
const sync = useSyncStore()
// Computed — derived from sync store's data
const serverName = computed(() => sync.serverInfo?.name || 'Archipelago')
const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false)
const isShuttingDown = computed(() => sync.serverInfo?.['status-info']?.['shutting-down'] || false)
const isOffline = computed(() => !sync.isConnected || isRestarting.value || isShuttingDown.value)
// Package actions
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
return rpcClient.installPackage(id, marketplaceUrl, version)
}
async function uninstallPackage(id: string): Promise<void> {
return rpcClient.uninstallPackage(id)
}
async function startPackage(id: string): Promise<void> {
return rpcClient.startPackage(id)
}
async function stopPackage(id: string): Promise<void> {
return rpcClient.stopPackage(id)
}
async function restartPackage(id: string): Promise<void> {
return rpcClient.restartPackage(id)
}
// Server actions
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
return rpcClient.updateServer(marketplaceUrl)
}
async function restartServer(): Promise<void> {
return rpcClient.restartServer()
}
async function shutdownServer(): Promise<void> {
return rpcClient.shutdownServer()
}
async function getMetrics(): Promise<Record<string, unknown>> {
return rpcClient.getMetrics()
}
// Marketplace actions
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
return rpcClient.getMarketplace(url)
}
function updateServerName(name: string) {
if (sync.data?.['server-info']) {
sync.data['server-info'].name = name
}
}
return {
// Computed
serverName,
isRestarting,
isShuttingDown,
isOffline,
// Actions
installPackage,
uninstallPackage,
startPackage,
stopPackage,
restartPackage,
updateServer,
restartServer,
shutdownServer,
getMetrics,
getMarketplace,
updateServerName,
}
})

179
neode-ui/src/stores/sync.ts Normal file
View File

@@ -0,0 +1,179 @@
// Sync store — WebSocket connection, real-time data, patch application
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 useSyncStore = defineStore('sync', () => {
// State
const data = ref<DataModel | null>(null)
const isConnected = ref(false)
const isReconnecting = ref(false)
let isWsSubscribed = false
let isWsConnecting = false
// Computed
const serverInfo = computed(() => data.value?.['server-info'])
const packages = computed(() => data.value?.['package-data'] || {})
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
const uiData = computed(() => data.value?.ui)
// Actions
async function connectWebSocket(): Promise<void> {
// Prevent concurrent connection attempts
if (isWsConnecting) return
isWsConnecting = true
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')
// Fetch fresh state after reconnect to avoid stale patch application
try {
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
if (freshState?.data) {
data.value = freshState.data
}
} catch {
// Non-fatal: WebSocket patches will still work
if (import.meta.env.DEV) console.warn('[Store] Failed to fetch fresh state after reconnect')
}
// 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
} finally {
isWsConnecting = false
}
}
async function initializeData(): Promise<void> {
// 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',
},
}
}
/** Reset sync state on logout — called by auth store */
function resetOnLogout(): void {
data.value = null
isWsSubscribed = false
wsClient.disconnect()
isConnected.value = false
isReconnecting.value = false
}
return {
// State
data,
isConnected,
isReconnecting,
// Computed
serverInfo,
packages,
peerHealth,
uiData,
// Actions
connectWebSocket,
initializeData,
resetOnLogout,
}
})