| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- /**
- * TableContext - Multi-table state management
- *
- * Manages discovered tables, active table selection, and persistence.
- * When the active table changes, the API client's base URL is updated
- * and components can react to reconnect WebSockets.
- */
- import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
- import { apiClient } from '@/lib/apiClient'
- export interface Table {
- id: string
- name: string
- appName?: string // Application name from settings (e.g., "Dune Weaver")
- url: string
- host?: string
- port?: number
- version?: string
- isOnline?: boolean
- isCurrent?: boolean // True if this is the backend serving the frontend
- customLogo?: string // Custom logo filename if set (e.g., "logo_abc123.png")
- }
- interface TableContextType {
- // State
- tables: Table[]
- activeTable: Table | null
- isDiscovering: boolean
- lastDiscovery: Date | null
- // Actions
- setActiveTable: (table: Table) => void
- discoverTables: () => Promise<void>
- addTable: (url: string, name?: string) => Promise<Table | null>
- removeTable: (id: string) => void
- updateTableName: (id: string, name: string) => Promise<void>
- refreshTableStatus: (table: Table) => Promise<boolean>
- }
- const TableContext = createContext<TableContextType | null>(null)
- const STORAGE_KEY = 'duneweaver_tables'
- const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
- /**
- * Normalize a URL to its origin for comparison purposes.
- * This handles port normalization (e.g., :80 for HTTP is stripped).
- * Returns the origin or the original string if parsing fails.
- */
- function normalizeUrlOrigin(url: string): string {
- try {
- return new URL(url).origin
- } catch {
- return url
- }
- }
- interface StoredTableData {
- tables: Table[]
- activeTableId: string | null
- }
- export function TableProvider({ children }: { children: React.ReactNode }) {
- const [tables, setTables] = useState<Table[]>([])
- const [activeTable, setActiveTableState] = useState<Table | null>(null)
- const [isDiscovering, setIsDiscovering] = useState(false)
- const [lastDiscovery, setLastDiscovery] = useState<Date | null>(null)
- const initializedRef = useRef(false)
- const restoredActiveIdRef = useRef<string | null>(null) // Track restored selection
- // Load saved tables from localStorage on mount
- useEffect(() => {
- if (initializedRef.current) return
- initializedRef.current = true
- try {
- const stored = localStorage.getItem(STORAGE_KEY)
- const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
- if (stored) {
- const data: StoredTableData = JSON.parse(stored)
- setTables(data.tables || [])
- // Restore active table
- if (activeId && data.tables) {
- const active = data.tables.find(t => t.id === activeId)
- if (active) {
- restoredActiveIdRef.current = activeId // Mark that we restored a selection
- setActiveTableState(active)
- // Set base URL for remote tables (tables not on the current origin)
- // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
- // Note: apiClient pre-initializes from localStorage, but this ensures consistency
- if (normalizeUrlOrigin(active.url) !== window.location.origin) {
- apiClient.setBaseUrl(active.url)
- }
- }
- }
- }
- // Always refresh to ensure current table is available and up-to-date
- discoverTables()
- } catch (e) {
- console.error('Failed to load saved tables:', e)
- discoverTables()
- }
- }, [])
- // Save tables to localStorage when they change
- useEffect(() => {
- if (!initializedRef.current) return
- try {
- const data: StoredTableData = {
- tables,
- activeTableId: activeTable?.id || null,
- }
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
- if (activeTable) {
- localStorage.setItem(ACTIVE_TABLE_KEY, activeTable.id)
- } else {
- localStorage.removeItem(ACTIVE_TABLE_KEY)
- }
- } catch (e) {
- console.error('Failed to save tables:', e)
- }
- }, [tables, activeTable])
- // Set active table - saves to localStorage and reloads page for clean state
- const setActiveTable = useCallback((table: Table) => {
- // Save to localStorage before reload
- try {
- const currentTables = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
- const data: StoredTableData = {
- tables: currentTables.tables || tables,
- activeTableId: table.id,
- }
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
- localStorage.setItem(ACTIVE_TABLE_KEY, table.id)
- } catch (e) {
- console.error('Failed to save table selection:', e)
- }
- // Update API client base URL
- // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
- if (normalizeUrlOrigin(table.url) === window.location.origin) {
- apiClient.setBaseUrl('')
- } else {
- apiClient.setBaseUrl(table.url)
- }
- // Reload page for clean state (WebSockets, caches, etc.)
- window.location.reload()
- }, [tables])
- // Refresh tables - ensures current table is always available
- const discoverTables = useCallback(async () => {
- setIsDiscovering(true)
- try {
- // Fetch table info, settings, and known tables in parallel
- const [infoResponse, settingsResponse, knownTablesResponse] = await Promise.all([
- fetch('/api/table-info'),
- fetch('/api/settings').catch(() => null),
- fetch('/api/known-tables').catch(() => null),
- ])
- if (!infoResponse.ok) {
- throw new Error('Failed to fetch table info')
- }
- const info = await infoResponse.json()
- const settings = settingsResponse?.ok ? await settingsResponse.json() : null
- const knownTablesData = knownTablesResponse?.ok ? await knownTablesResponse.json() : null
- const knownTables: Array<{ id: string; name: string; url: string; host?: string; port?: number; version?: string }> = knownTablesData?.tables || []
- const currentTable: Table = {
- id: info.id,
- name: info.name,
- url: window.location.origin,
- version: info.version,
- isOnline: true,
- isCurrent: true,
- customLogo: settings?.app?.custom_logo || undefined,
- }
- // Merge current table with known tables from backend
- setTables(() => {
- // Start with current table
- const merged: Table[] = [currentTable]
- // Add known tables from backend (these are persisted remote tables)
- knownTables.forEach(known => {
- if (known.id !== currentTable.id) {
- merged.push({
- id: known.id,
- name: known.name,
- url: known.url,
- host: known.host,
- port: known.port,
- version: known.version,
- isOnline: false, // Will be updated by background refresh
- isCurrent: false,
- })
- }
- })
- return merged
- })
- // If no active table AND no restored selection, select the current one
- // Use ref to check restored selection because activeTable state may not be updated yet
- if (!activeTable && !restoredActiveIdRef.current) {
- // For initial selection of current table, just update state without reload
- // Reload is only needed when switching between DIFFERENT tables
- setActiveTableState(currentTable)
- // Save to localStorage so it persists
- try {
- const data: StoredTableData = {
- tables: [currentTable],
- activeTableId: currentTable.id,
- }
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
- localStorage.setItem(ACTIVE_TABLE_KEY, currentTable.id)
- } catch (e) {
- console.error('Failed to save initial table selection:', e)
- }
- } else if (activeTable?.isCurrent) {
- // Update active table name if it changed on the backend
- setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
- }
- // Clear the restored ref after first discovery
- restoredActiveIdRef.current = null
- setLastDiscovery(new Date())
- // Refresh remote tables in the background to get their customLogo
- // Use setTimeout to not block the main discovery flow
- setTimeout(() => {
- setTables(currentTables => {
- const remoteTables = currentTables.filter(t => !t.isCurrent)
- remoteTables.forEach(async (table) => {
- try {
- const [infoResponse, settingsResponse] = await Promise.all([
- fetch(`${table.url}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
- fetch(`${table.url}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
- ])
- const isOnline = infoResponse.ok
- const settings = settingsResponse?.ok ? await settingsResponse.json() : null
- const customLogo = settings?.app?.custom_logo || undefined
- setTables(prev =>
- prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
- )
- } catch {
- setTables(prev =>
- prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
- )
- }
- })
- return currentTables // Return unchanged for now, updates happen in the async callbacks
- })
- }, 100)
- } catch (e) {
- console.error('Table refresh failed:', e)
- } finally {
- setIsDiscovering(false)
- }
- }, [activeTable]) // Only depends on activeTable for checking if we need to update name
- // Add a table manually by URL
- const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {
- try {
- // Normalize URL
- const normalizedUrl = url.replace(/\/$/, '')
- // Check if already exists
- if (tables.find(t => t.url === normalizedUrl)) {
- return null
- }
- // Fetch table info and settings in parallel
- const [infoResponse, settingsResponse] = await Promise.all([
- fetch(`${normalizedUrl}/api/table-info`),
- fetch(`${normalizedUrl}/api/settings`).catch(() => null),
- ])
- if (!infoResponse.ok) {
- throw new Error('Failed to fetch table info')
- }
- const info = await infoResponse.json()
- const settings = settingsResponse?.ok ? await settingsResponse.json() : null
- const newTable: Table = {
- id: info.id,
- name: name || info.name,
- url: normalizedUrl,
- version: info.version,
- isOnline: true,
- isCurrent: false,
- customLogo: settings?.app?.custom_logo || undefined,
- }
- // Persist to backend
- try {
- const hostname = new URL(normalizedUrl).hostname
- await fetch('/api/known-tables', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- id: newTable.id,
- name: newTable.name,
- url: newTable.url,
- host: hostname,
- version: newTable.version,
- }),
- })
- } catch (e) {
- console.error('Failed to persist table to backend:', e)
- // Continue anyway - table will still work for this session
- }
- setTables(prev => [...prev, newTable])
- return newTable
- } catch (e) {
- console.error('Failed to add table:', e)
- return null
- }
- }, [tables])
- // Remove a table
- const removeTable = useCallback(async (id: string) => {
- // Remove from backend
- try {
- await fetch(`/api/known-tables/${id}`, { method: 'DELETE' })
- } catch (e) {
- console.error('Failed to remove table from backend:', e)
- // Continue anyway - remove from local state
- }
- setTables(prev => prev.filter(t => t.id !== id))
- // If removing active table, switch to another
- if (activeTable?.id === id) {
- const remaining = tables.filter(t => t.id !== id)
- if (remaining.length > 0) {
- setActiveTable(remaining[0])
- } else {
- setActiveTableState(null)
- apiClient.setBaseUrl('')
- }
- }
- }, [activeTable, tables, setActiveTable])
- // Update table name (on the backend)
- const updateTableName = useCallback(async (id: string, name: string) => {
- const table = tables.find(t => t.id === id)
- if (!table) return
- try {
- const baseUrl = table.isCurrent ? '' : table.url
- const response = await fetch(`${baseUrl}/api/table-info`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name }),
- })
- if (response.ok) {
- // Also update the known table name in the current backend (for remote tables)
- if (!table.isCurrent) {
- try {
- await fetch(`/api/known-tables/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name }),
- })
- } catch (e) {
- console.error('Failed to update known table name:', e)
- }
- }
- setTables(prev =>
- prev.map(t => (t.id === id ? { ...t, name } : t))
- )
- // Update active table if it's the one being renamed
- if (activeTable?.id === id) {
- setActiveTableState(prev => prev ? { ...prev, name } : null)
- }
- }
- } catch (e) {
- console.error('Failed to update table name:', e)
- }
- }, [tables, activeTable])
- // Check if a table is online and update its info (including custom logo)
- const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
- try {
- const baseUrl = table.isCurrent ? '' : table.url
- // Fetch table info and settings in parallel
- const [infoResponse, settingsResponse] = await Promise.all([
- fetch(`${baseUrl}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
- fetch(`${baseUrl}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
- ])
- const isOnline = infoResponse.ok
- const settings = settingsResponse?.ok ? await settingsResponse.json() : null
- const customLogo = settings?.app?.custom_logo || undefined
- setTables(prev =>
- prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
- )
- return isOnline
- } catch {
- setTables(prev =>
- prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
- )
- return false
- }
- }, [])
- return (
- <TableContext.Provider
- value={{
- tables,
- activeTable,
- isDiscovering,
- lastDiscovery,
- setActiveTable,
- discoverTables,
- addTable,
- removeTable,
- updateTableName,
- refreshTableStatus,
- }}
- >
- {children}
- </TableContext.Provider>
- )
- }
- export function useTable() {
- const context = useContext(TableContext)
- if (!context) {
- throw new Error('useTable must be used within a TableProvider')
- }
- return context
- }
- // Hook for subscribing to active table changes (for WebSocket reconnection)
- export function useActiveTableChange(callback: (table: Table | null) => void) {
- const { activeTable } = useTable()
- const callbackRef = useRef(callback)
- const prevTableRef = useRef<Table | null>(null)
- callbackRef.current = callback
- useEffect(() => {
- // Only call on actual changes, not initial render
- if (prevTableRef.current !== null || activeTable !== null) {
- if (prevTableRef.current?.id !== activeTable?.id) {
- callbackRef.current(activeTable)
- }
- }
- prevTableRef.current = activeTable
- }, [activeTable])
- }
|