Enhance development workflow and deployment practices for Archipelago
- Updated the Development-Workflow documentation to clarify deployment strategy, emphasizing direct deployment to the live system for testing. - Added detailed instructions for the deployment command, including syncing code, building frontend and backend, and restarting services. - Improved SSH key management section to assist with authentication issues. - Expanded the testing workflow to include steps for checking logs and syncing changes back to the ISO build. - Updated the ISO build integration section to ensure system-level changes are captured for future builds. - Refactored various sections for clarity and completeness, including deployment paths and system configuration files.
This commit is contained in:
@@ -4,19 +4,54 @@ import type { Update, PatchOperation } from '../types/api'
|
||||
import { applyPatch } from 'fast-json-patch'
|
||||
|
||||
type WebSocketCallback = (update: Update) => void
|
||||
type ConnectionStateCallback = (connected: boolean) => void
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null
|
||||
private callbacks: Set<WebSocketCallback> = new Set()
|
||||
private connectionStateCallbacks: Set<ConnectionStateCallback> = new Set()
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private shouldReconnect = true
|
||||
private url: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private visibilityChangeHandler: (() => void) | null = null
|
||||
private onlineHandler: (() => void) | null = null
|
||||
|
||||
constructor(url: string = '/ws/db') {
|
||||
this.url = url
|
||||
this.setupBrowserEventHandlers()
|
||||
}
|
||||
|
||||
private setupBrowserEventHandlers(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Handle page visibility changes (tab switching, browser minimizing)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
console.log('[WebSocket] Page became visible, checking connection...')
|
||||
// Reconnect if connection was lost while tab was hidden
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', this.visibilityChangeHandler)
|
||||
|
||||
// Handle network online/offline events
|
||||
this.onlineHandler = () => {
|
||||
console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('online', this.onlineHandler)
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
@@ -36,9 +71,9 @@ export class WebSocketClient {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkInterval)
|
||||
resolve()
|
||||
} else if (this.ws.readyState === WebSocket.CLOSED) {
|
||||
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||
clearInterval(checkInterval)
|
||||
// Connection failed, will be handled by onclose
|
||||
// Connection failed or closing, will be handled by onclose
|
||||
reject(new Error('Connection closed during connect'))
|
||||
}
|
||||
} else {
|
||||
@@ -57,19 +92,17 @@ export class WebSocketClient {
|
||||
return
|
||||
}
|
||||
|
||||
// Close existing connection if any (but don't prevent reconnection)
|
||||
if (this.ws) {
|
||||
const oldWs = this.ws
|
||||
// Don't close existing connection if it's still active
|
||||
// Only close if it's in CLOSING or CLOSED state
|
||||
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
|
||||
this.ws = null
|
||||
// Temporarily disable reconnection to prevent loop
|
||||
const wasReconnecting = this.shouldReconnect
|
||||
this.shouldReconnect = false
|
||||
oldWs.onclose = null // Remove close handler
|
||||
oldWs.close()
|
||||
// Restore reconnection flag after a moment
|
||||
setTimeout(() => {
|
||||
this.shouldReconnect = wasReconnecting
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// If we have an active WebSocket, don't create a new one
|
||||
if (this.ws) {
|
||||
console.log('[WebSocket] Connection exists, reusing it')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Reset shouldReconnect flag when explicitly connecting
|
||||
@@ -100,6 +133,7 @@ export class WebSocketClient {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.reconnectAttempts = 0
|
||||
console.log('[WebSocket] Connected successfully')
|
||||
this.notifyConnectionState(true)
|
||||
resolve()
|
||||
}
|
||||
|
||||
@@ -123,6 +157,9 @@ export class WebSocketClient {
|
||||
clearTimeout(connectionTimeout)
|
||||
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
|
||||
// Notify connection state changed
|
||||
this.notifyConnectionState(false)
|
||||
|
||||
// Clear the WebSocket reference
|
||||
this.ws = null
|
||||
|
||||
@@ -133,12 +170,19 @@ export class WebSocketClient {
|
||||
}
|
||||
|
||||
// Always try to reconnect unless we've exceeded max attempts
|
||||
// Code 1001 (Going Away) happens on HMR reloads - reconnect IMMEDIATELY
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
// Only code 1001 is HMR, NOT 1006 (1006 is abnormal closure)
|
||||
const isHMR = event.code === 1001
|
||||
const delay = isHMR ? 0 : (this.reconnectAttempts === 0 ? 100 : Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code}, HMR: ${isHMR})`)
|
||||
const isNormalClosure = event.code === 1000 || event.code === 1001
|
||||
const isServiceRestart = event.code === 1012
|
||||
|
||||
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
|
||||
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
|
||||
|
||||
const delay = needsImmediateReconnect ? 0 :
|
||||
(this.reconnectAttempts === 0 ? 100 :
|
||||
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
||||
|
||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
@@ -152,8 +196,8 @@ export class WebSocketClient {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't increment attempts for HMR disconnects - they're expected
|
||||
if (!isHMR) {
|
||||
// Don't increment attempts for expected disconnects (HMR, normal closure)
|
||||
if (!isHMR && !isNormalClosure) {
|
||||
this.reconnectAttempts++
|
||||
}
|
||||
|
||||
@@ -165,7 +209,7 @@ export class WebSocketClient {
|
||||
}
|
||||
|
||||
if (delay === 0) {
|
||||
// Immediate reconnection for HMR
|
||||
// Immediate reconnection
|
||||
doReconnect()
|
||||
} else {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
@@ -188,6 +232,17 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||
this.connectionStateCallbacks.add(callback)
|
||||
return () => {
|
||||
this.connectionStateCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyConnectionState(connected: boolean): void {
|
||||
this.connectionStateCallbacks.forEach((callback) => callback(connected))
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.reconnectAttempts = 0
|
||||
@@ -214,6 +269,16 @@ export class WebSocketClient {
|
||||
reset(): void {
|
||||
this.disconnect()
|
||||
this.callbacks.clear()
|
||||
|
||||
// Clean up browser event handlers
|
||||
if (this.visibilityChangeHandler) {
|
||||
document.removeEventListener('visibilitychange', this.visibilityChangeHandler)
|
||||
this.visibilityChangeHandler = null
|
||||
}
|
||||
if (this.onlineHandler) {
|
||||
window.removeEventListener('online', this.onlineHandler)
|
||||
this.onlineHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
|
||||
@@ -170,6 +170,15 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// User is already authenticated (from localStorage on page load)
|
||||
// Make sure WebSocket is connected
|
||||
if (!store.isConnected && !store.isReconnecting) {
|
||||
console.log('[Router] User authenticated but WebSocket not connected, connecting...')
|
||||
store.connectWebSocket().catch((err) => {
|
||||
console.warn('[Router] WebSocket connection failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticated user accessing protected route
|
||||
next()
|
||||
})
|
||||
|
||||
@@ -74,6 +74,18 @@ export const useAppStore = defineStore('app', () => {
|
||||
if (!isWsSubscribed) {
|
||||
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
||||
isWsSubscribed = true
|
||||
|
||||
// Listen for connection state changes
|
||||
wsClient.onConnectionStateChange((connected) => {
|
||||
console.log('[Store] WebSocket connection state changed:', connected)
|
||||
isConnected.value = connected
|
||||
if (!connected) {
|
||||
isReconnecting.value = true
|
||||
} else {
|
||||
isReconnecting.value = false
|
||||
}
|
||||
})
|
||||
|
||||
wsClient.subscribe((update: any) => {
|
||||
// Handle mock backend format: {type: 'initial', data: {...}}
|
||||
if (update?.type === 'initial' && update?.data) {
|
||||
@@ -107,14 +119,29 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
|
||||
// Now connect (or reconnect if already connected)
|
||||
// Only attempt to connect if not already connected
|
||||
if (wsClient.isConnected()) {
|
||||
console.log('[Store] WebSocket already connected')
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await wsClient.connect()
|
||||
console.log('[Store] WebSocket connected')
|
||||
|
||||
// Connection state will be updated via the callback
|
||||
if (wsClient.isConnected()) {
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
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
|
||||
}
|
||||
|
||||
+58
-36
@@ -81,16 +81,38 @@
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@click.stop="startApp(id as string)"
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors"
|
||||
:disabled="loadingActions[id as string]"
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Start
|
||||
<svg
|
||||
v-if="loadingActions[id as string]"
|
||||
class="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ loadingActions[id as string] ? 'Starting...' : 'Start' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@click.stop="stopApp(id as string)"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors"
|
||||
:disabled="loadingActions[id as string]"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Stop
|
||||
<svg
|
||||
v-if="loadingActions[id as string]"
|
||||
class="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ loadingActions[id as string] ? 'Stopping...' : 'Stop' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,6 +174,9 @@ import { PackageState } from '../types/api'
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
// Track loading states for each app action
|
||||
const loadingActions = ref<Record<string, boolean>>({})
|
||||
|
||||
// Use real packages from store - no more dummy apps
|
||||
const packages = computed(() => {
|
||||
const realPackages = store.packages
|
||||
@@ -185,38 +210,14 @@ function launchApp(id: string) {
|
||||
const isDev = import.meta.env.DEV
|
||||
const pkg = packages.value[id]
|
||||
|
||||
// Special handling for Bitcoin Core - open in new tab on port 18445
|
||||
if (id === 'bitcoin') {
|
||||
window.open('http://localhost:18445', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for LND - open in new tab on port 8085
|
||||
if (id === 'lnd') {
|
||||
window.open('http://localhost:8085', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for Penpot - open in new tab on port 9001
|
||||
if (id === 'penpot' || id === 'penpot-frontend') {
|
||||
window.open('http://localhost:9001', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for Morphos - open in new tab on port 8081
|
||||
if (id === 'morphos' || id === 'morphos-server') {
|
||||
window.open('http://localhost:8081', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for Nextcloud - open in new tab on port 8086
|
||||
if (id === 'nextcloud') {
|
||||
window.open('http://localhost:8086', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the LAN address from the package manifest
|
||||
const lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
let lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
|
||||
// Replace localhost with the current hostname (for remote access)
|
||||
if (lanAddress && lanAddress.includes('localhost')) {
|
||||
const currentHost = window.location.hostname
|
||||
lanAddress = lanAddress.replace('localhost', currentHost)
|
||||
}
|
||||
|
||||
if (lanAddress) {
|
||||
window.open(lanAddress, '_blank', 'noopener,noreferrer')
|
||||
@@ -236,7 +237,12 @@ function launchApp(id: string) {
|
||||
}
|
||||
|
||||
if (appUrls[id]) {
|
||||
const url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||
let url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||
// Replace localhost with current hostname for remote access
|
||||
if (url.includes('localhost')) {
|
||||
const currentHost = window.location.hostname
|
||||
url = url.replace('localhost', currentHost)
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
@@ -267,18 +273,34 @@ function goToApp(id: string) {
|
||||
}
|
||||
|
||||
async function startApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.startPackage(id)
|
||||
// Wait for state update from WebSocket
|
||||
// The loader will be cleared when we receive the updated state
|
||||
// For now, keep a max timeout as fallback
|
||||
setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
}, 5000)
|
||||
} catch (err) {
|
||||
console.error('Failed to start app:', err)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.stopPackage(id)
|
||||
// Wait for state update from WebSocket
|
||||
// The loader will be cleared when we receive the updated state
|
||||
// For now, keep a max timeout as fallback
|
||||
setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
}, 5000)
|
||||
} catch (err) {
|
||||
console.error('Failed to stop app:', err)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Bundled Apps -->
|
||||
<div
|
||||
v-for="app in BUNDLED_APPS"
|
||||
v-for="app in bundledApps"
|
||||
:key="app.id"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
@@ -189,6 +189,9 @@ import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const store = useContainerStore()
|
||||
|
||||
// Expose BUNDLED_APPS to the template (prevents tree-shaking)
|
||||
const bundledApps = BUNDLED_APPS
|
||||
|
||||
// Get current host for launch URLs
|
||||
const currentHost = computed(() => window.location.hostname)
|
||||
|
||||
@@ -205,14 +208,14 @@ onMounted(async () => {
|
||||
|
||||
// Containers that aren't bundled apps
|
||||
const otherContainers = computed(() => {
|
||||
const bundledIds = BUNDLED_APPS.map(a => a.id)
|
||||
const bundledIds = bundledApps.map(a => a.id)
|
||||
return store.containers.filter(c => {
|
||||
const name = c.name.toLowerCase()
|
||||
return !bundledIds.some(id => name.includes(id))
|
||||
})
|
||||
})
|
||||
|
||||
const hasAnyApps = computed(() => BUNDLED_APPS.length > 0 || store.containers.length > 0)
|
||||
const hasAnyApps = computed(() => bundledApps.length > 0 || store.containers.length > 0)
|
||||
|
||||
function extractAppName(containerName: string): string {
|
||||
return containerName
|
||||
|
||||
Reference in New Issue
Block a user