diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index a78fb6f..a003230 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,7 +1,9 @@
+
diff --git a/frontend/src/components/ToastNotification.vue b/frontend/src/components/ToastNotification.vue
new file mode 100644
index 0000000..e304f16
--- /dev/null
+++ b/frontend/src/components/ToastNotification.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
{{ toast.message }}
+
+
+
+
+
diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts
new file mode 100644
index 0000000..1c03ff8
--- /dev/null
+++ b/frontend/src/composables/useWebSocket.ts
@@ -0,0 +1,213 @@
+import { ref, computed, watch, onUnmounted } from 'vue'
+import { useAuthStore } from '@/stores/auth'
+
+export interface WebSocketMessage {
+ type: 'connected' | 'event' | 'error'
+ license_id?: string
+ subscribed_to?: string
+ event?: string
+ subject?: string
+ data?: any
+ raw?: string
+ message?: string
+}
+
+type MessageHandler = (message: WebSocketMessage) => void
+
+export function useWebSocket() {
+ const authStore = useAuthStore()
+
+ const ws = ref(null)
+ const isConnected = ref(false)
+ const isConnecting = ref(false)
+ const error = ref(null)
+ const reconnectAttempts = ref(0)
+ const maxReconnectAttempts = 10
+ const baseReconnectDelay = 1000 // 1 second
+
+ const messageHandlers = new Set()
+ let reconnectTimeout: number | null = null
+
+ const status = computed(() => {
+ if (isConnected.value) return 'connected'
+ if (isConnecting.value) return 'connecting'
+ if (error.value) return 'error'
+ return 'disconnected'
+ })
+
+ function getWebSocketUrl(): string {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+ const host = import.meta.env.PROD
+ ? window.location.host
+ : 'localhost:3000'
+ const token = authStore.accessToken
+
+ if (!token) {
+ throw new Error('No access token available for WebSocket authentication')
+ }
+
+ return `${protocol}//${host}/api/ws?token=${encodeURIComponent(token)}`
+ }
+
+ function connect() {
+ if (!authStore.isAuthenticated) {
+ console.log('[WebSocket] Not authenticated, skipping connection')
+ return
+ }
+
+ if (isConnecting.value || isConnected.value) {
+ console.log('[WebSocket] Already connecting or connected')
+ return
+ }
+
+ try {
+ isConnecting.value = true
+ error.value = null
+
+ const url = getWebSocketUrl()
+ console.log('[WebSocket] Connecting to', url.replace(/token=[^&]+/, 'token=***'))
+
+ ws.value = new WebSocket(url)
+
+ ws.value.onopen = () => {
+ console.log('[WebSocket] Connected')
+ isConnected.value = true
+ isConnecting.value = false
+ reconnectAttempts.value = 0
+ error.value = null
+ }
+
+ ws.value.onmessage = (event) => {
+ try {
+ const message: WebSocketMessage = JSON.parse(event.data)
+ console.log('[WebSocket] Message received:', message)
+
+ // Broadcast to all handlers
+ messageHandlers.forEach(handler => {
+ try {
+ handler(message)
+ } catch (err) {
+ console.error('[WebSocket] Handler error:', err)
+ }
+ })
+ } catch (err) {
+ console.error('[WebSocket] Failed to parse message:', err)
+ }
+ }
+
+ ws.value.onerror = (event) => {
+ console.error('[WebSocket] Error:', event)
+ error.value = 'WebSocket connection error'
+ isConnecting.value = false
+ }
+
+ ws.value.onclose = (event) => {
+ console.log('[WebSocket] Closed:', event.code, event.reason)
+ isConnected.value = false
+ isConnecting.value = false
+
+ // Attempt reconnect if not a clean close or max attempts not reached
+ if (!event.wasClean && reconnectAttempts.value < maxReconnectAttempts) {
+ scheduleReconnect()
+ } else if (reconnectAttempts.value >= maxReconnectAttempts) {
+ error.value = 'Max reconnection attempts reached'
+ }
+ }
+ } catch (err) {
+ console.error('[WebSocket] Connection failed:', err)
+ error.value = err instanceof Error ? err.message : 'Unknown connection error'
+ isConnecting.value = false
+ scheduleReconnect()
+ }
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimeout) {
+ clearTimeout(reconnectTimeout)
+ }
+
+ reconnectAttempts.value++
+ const delay = Math.min(
+ baseReconnectDelay * Math.pow(2, reconnectAttempts.value - 1),
+ 30000 // Max 30 seconds
+ )
+
+ console.log(
+ `[WebSocket] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts})`
+ )
+
+ reconnectTimeout = window.setTimeout(() => {
+ connect()
+ }, delay)
+ }
+
+ function disconnect() {
+ if (reconnectTimeout) {
+ clearTimeout(reconnectTimeout)
+ reconnectTimeout = null
+ }
+
+ if (ws.value) {
+ console.log('[WebSocket] Disconnecting')
+ ws.value.close(1000, 'Client disconnect')
+ ws.value = null
+ }
+
+ isConnected.value = false
+ isConnecting.value = false
+ reconnectAttempts.value = 0
+ }
+
+ function send(data: any) {
+ if (!ws.value || !isConnected.value) {
+ console.warn('[WebSocket] Cannot send, not connected')
+ return false
+ }
+
+ try {
+ ws.value.send(JSON.stringify(data))
+ return true
+ } catch (err) {
+ console.error('[WebSocket] Send failed:', err)
+ return false
+ }
+ }
+
+ function subscribe(handler: MessageHandler) {
+ messageHandlers.add(handler)
+
+ // Return unsubscribe function
+ return () => {
+ messageHandlers.delete(handler)
+ }
+ }
+
+ // Auto-connect when authenticated
+ watch(
+ () => authStore.isAuthenticated,
+ (authenticated) => {
+ if (authenticated) {
+ connect()
+ } else {
+ disconnect()
+ }
+ },
+ { immediate: true }
+ )
+
+ // Cleanup on unmount
+ onUnmounted(() => {
+ disconnect()
+ })
+
+ return {
+ isConnected,
+ isConnecting,
+ status,
+ error,
+ connect,
+ disconnect,
+ send,
+ subscribe,
+ }
+}
diff --git a/frontend/src/stores/toast.ts b/frontend/src/stores/toast.ts
new file mode 100644
index 0000000..efc250c
--- /dev/null
+++ b/frontend/src/stores/toast.ts
@@ -0,0 +1,66 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export interface Toast {
+ id: string
+ type: 'success' | 'error' | 'warning' | 'info'
+ message: string
+ duration?: number
+}
+
+export const useToastStore = defineStore('toast', () => {
+ const toasts = ref([])
+
+ function addToast(toast: Omit) {
+ const id = crypto.randomUUID()
+ const duration = toast.duration ?? (toast.type === 'error' ? 8000 : 5000)
+
+ const newToast: Toast = {
+ ...toast,
+ id,
+ duration,
+ }
+
+ toasts.value.push(newToast)
+
+ // Auto-remove after duration
+ if (duration > 0) {
+ setTimeout(() => {
+ removeToast(id)
+ }, duration)
+ }
+ }
+
+ function removeToast(id: string) {
+ const index = toasts.value.findIndex(t => t.id === id)
+ if (index !== -1) {
+ toasts.value.splice(index, 1)
+ }
+ }
+
+ function success(message: string, duration?: number) {
+ addToast({ type: 'success', message, duration })
+ }
+
+ function error(message: string, duration?: number) {
+ addToast({ type: 'error', message, duration })
+ }
+
+ function warning(message: string, duration?: number) {
+ addToast({ type: 'warning', message, duration })
+ }
+
+ function info(message: string, duration?: number) {
+ addToast({ type: 'info', message, duration })
+ }
+
+ return {
+ toasts,
+ addToast,
+ removeToast,
+ success,
+ error,
+ warning,
+ info,
+ }
+})
diff --git a/frontend/src/stores/wipe.ts b/frontend/src/stores/wipe.ts
index b3d34c5..e697fa6 100644
--- a/frontend/src/stores/wipe.ts
+++ b/frontend/src/stores/wipe.ts
@@ -1,31 +1,286 @@
import { defineStore } from 'pinia'
-import { ref } from 'vue'
+import { ref, onMounted } from 'vue'
import type { WipeProfile, WipeSchedule, WipeHistory } from '@/types'
+import { useApi } from '@/composables/useApi'
+import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
+import { useAuthStore } from '@/stores/auth'
+import { useToastStore } from '@/stores/toast'
export const useWipeStore = defineStore('wipe', () => {
+ const api = useApi()
+ const authStore = useAuthStore()
+ const websocket = useWebSocket()
+ const toast = useToastStore()
+
const profiles = ref([])
const schedules = ref([])
const history = ref([])
const isLoading = ref(false)
+ const error = ref(null)
+
+ // WebSocket event handler
+ function handleWebSocketMessage(message: WebSocketMessage) {
+ if (message.type !== 'event') return
+
+ const { event, data } = message
+
+ switch (event) {
+ case 'wipe_started':
+ // Update wipe history with new status
+ if (data?.wipe_history_id) {
+ updateWipeHistoryItem(data)
+ }
+ toast.info(`Wipe started: ${data?.wipe_type || 'unknown'} wipe in progress`)
+ break
+
+ case 'wipe_status':
+ // Update wipe history with status progress
+ if (data?.wipe_history_id) {
+ updateWipeHistoryItem(data)
+ }
+ break
+
+ case 'wipe_completed':
+ toast.success(`Wipe completed successfully in ${data?.duration || '?'} seconds`)
+ // Refresh history to get final state
+ fetchHistory().catch(console.error)
+ break
+
+ case 'wipe_failed':
+ toast.error(`Wipe failed: ${data?.error_message || 'Unknown error'}`, 10000)
+ // Refresh history to get final state
+ fetchHistory().catch(console.error)
+ break
+
+ default:
+ // Ignore other events
+ break
+ }
+ }
+
+ // Update a single wipe history item from WebSocket data
+ function updateWipeHistoryItem(data: any) {
+ const id = data.wipe_history_id || data.id
+ if (!id) return
+
+ const index = history.value.findIndex(h => h.id === id)
+ if (index !== -1 && history.value[index]) {
+ // Update existing item (merge partial data)
+ const current = history.value[index]
+
+ // Only update fields that are present in data
+ if (data.status) current.status = data.status
+ if (data.started_at) current.started_at = data.started_at
+ if (data.completed_at) current.completed_at = data.completed_at
+ if (data.error_message !== undefined) current.error_message = data.error_message
+ if (data.map_used) current.map_used = data.map_used
+ if (data.plugins_wiped) current.plugins_wiped = data.plugins_wiped
+ if (data.plugins_preserved) current.plugins_preserved = data.plugins_preserved
+ } else {
+ // Add new item if it doesn't exist (shouldn't happen, but defensive)
+ fetchHistory().catch(console.error)
+ }
+ }
+
+ // Subscribe to WebSocket events when store is initialized
+ onMounted(() => {
+ websocket.subscribe(handleWebSocketMessage)
+ })
async function fetchProfiles() {
- // TODO: GET /api/profiles
+ isLoading.value = true
+ error.value = null
+ try {
+ const data = await api.get('/profiles')
+ profiles.value = data
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to fetch profiles'
+ console.error('Failed to fetch wipe profiles:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
}
async function fetchSchedules() {
- // TODO: GET /api/schedules
+ isLoading.value = true
+ error.value = null
+ try {
+ const data = await api.get('/schedules')
+ schedules.value = data
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to fetch schedules'
+ console.error('Failed to fetch wipe schedules:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
}
- async function fetchHistory() {
- // TODO: GET /api/wipes/history
+ async function fetchHistory(limit = 50) {
+ isLoading.value = true
+ error.value = null
+ try {
+ const licenseId = authStore.license?.id
+ if (!licenseId) {
+ throw new Error('No license ID available')
+ }
+
+ const data = await api.get(`/wipes/history?limit=${limit}`)
+ history.value = data
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to fetch wipe history'
+ console.error('Failed to fetch wipe history:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
}
- async function triggerWipe(_wipeType: string, _profileId: string) {
- // TODO: POST /api/wipes/:server_id/trigger
+ async function triggerWipe(
+ wipeType: 'map' | 'blueprint' | 'full',
+ profileId: string
+ ): Promise<{ wipe_history_id: string }> {
+ isLoading.value = true
+ error.value = null
+ try {
+ const licenseId = authStore.license?.id
+ if (!licenseId) {
+ throw new Error('No license ID available')
+ }
+
+ const result = await api.post<{ wipe_history_id: string }>(
+ `/wipes/trigger`,
+ {
+ license_id: licenseId,
+ wipe_type: wipeType,
+ profile_id: profileId,
+ trigger_type: 'manual',
+ }
+ )
+
+ // Optimistically add pending wipe to history
+ const newWipe: WipeHistory = {
+ id: result.wipe_history_id,
+ wipe_type: wipeType,
+ trigger_type: 'manual',
+ status: 'pending',
+ started_at: null,
+ completed_at: null,
+ map_used: null,
+ plugins_wiped: [],
+ plugins_preserved: [],
+ error_message: null,
+ }
+ history.value.unshift(newWipe)
+
+ toast.success(`${wipeType.charAt(0).toUpperCase() + wipeType.slice(1)} wipe triggered successfully`)
+
+ return result
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to trigger wipe'
+ console.error('Failed to trigger wipe:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
}
- async function triggerDryRun(_wipeType: string, _profileId: string) {
- // TODO: POST /api/wipes/:server_id/dry-run
+ async function triggerDryRun(
+ wipeType: 'map' | 'blueprint' | 'full',
+ profileId: string
+ ): Promise<{
+ would_delete: string[]
+ would_preserve: string[]
+ estimated_duration_seconds: number
+ }> {
+ isLoading.value = true
+ error.value = null
+ try {
+ const licenseId = authStore.license?.id
+ if (!licenseId) {
+ throw new Error('No license ID available')
+ }
+
+ const result = await api.post<{
+ would_delete: string[]
+ would_preserve: string[]
+ estimated_duration_seconds: number
+ }>(
+ `/wipes/dry-run`,
+ {
+ license_id: licenseId,
+ wipe_type: wipeType,
+ profile_id: profileId,
+ }
+ )
+
+ return result
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to run dry-run'
+ console.error('Failed to run dry-run:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ async function createProfile(profile: Omit): Promise {
+ isLoading.value = true
+ error.value = null
+ try {
+ const licenseId = authStore.license?.id
+ if (!licenseId) {
+ throw new Error('No license ID available')
+ }
+
+ const newProfile = await api.post('/profiles', {
+ ...profile,
+ license_id: licenseId,
+ })
+
+ profiles.value.push(newProfile)
+ return newProfile
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to create profile'
+ console.error('Failed to create profile:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ async function updateProfile(id: string, updates: Partial): Promise {
+ isLoading.value = true
+ error.value = null
+ try {
+ const updated = await api.put(`/profiles/${id}`, updates)
+ const index = profiles.value.findIndex(p => p.id === id)
+ if (index !== -1) {
+ profiles.value[index] = updated
+ }
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to update profile'
+ console.error('Failed to update profile:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ async function deleteProfile(id: string): Promise {
+ isLoading.value = true
+ error.value = null
+ try {
+ await api.del(`/profiles/${id}`)
+ profiles.value = profiles.value.filter(p => p.id !== id)
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to delete profile'
+ console.error('Failed to delete profile:', err)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
}
return {
@@ -33,10 +288,14 @@ export const useWipeStore = defineStore('wipe', () => {
schedules,
history,
isLoading,
+ error,
fetchProfiles,
fetchSchedules,
fetchHistory,
triggerWipe,
triggerDryRun,
+ createProfile,
+ updateProfile,
+ deleteProfile,
}
})
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4616ec3..418103e 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -19,6 +19,7 @@ export default defineConfig({
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
+ ws: true, // Enable WebSocket proxying
},
},
},