TableContext.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. /**
  2. * TableContext - Multi-table state management
  3. *
  4. * Manages discovered tables, active table selection, and persistence.
  5. * When the active table changes, the API client's base URL is updated
  6. * and components can react to reconnect WebSockets.
  7. */
  8. import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
  9. import { apiClient } from '@/lib/apiClient'
  10. export interface Table {
  11. id: string
  12. name: string
  13. url: string
  14. host?: string
  15. port?: number
  16. version?: string
  17. isOnline?: boolean
  18. isCurrent?: boolean // True if this is the backend serving the frontend
  19. }
  20. interface TableContextType {
  21. // State
  22. tables: Table[]
  23. activeTable: Table | null
  24. isDiscovering: boolean
  25. lastDiscovery: Date | null
  26. // Actions
  27. setActiveTable: (table: Table) => void
  28. discoverTables: () => Promise<void>
  29. addTable: (url: string, name?: string) => Promise<Table | null>
  30. removeTable: (id: string) => void
  31. updateTableName: (id: string, name: string) => Promise<void>
  32. refreshTableStatus: (table: Table) => Promise<boolean>
  33. }
  34. const TableContext = createContext<TableContextType | null>(null)
  35. const STORAGE_KEY = 'duneweaver_tables'
  36. const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
  37. interface StoredTableData {
  38. tables: Table[]
  39. activeTableId: string | null
  40. }
  41. export function TableProvider({ children }: { children: React.ReactNode }) {
  42. const [tables, setTables] = useState<Table[]>([])
  43. const [activeTable, setActiveTableState] = useState<Table | null>(null)
  44. const [isDiscovering, setIsDiscovering] = useState(false)
  45. const [lastDiscovery, setLastDiscovery] = useState<Date | null>(null)
  46. const initializedRef = useRef(false)
  47. const restoredActiveIdRef = useRef<string | null>(null) // Track restored selection
  48. // Load saved tables from localStorage on mount
  49. useEffect(() => {
  50. if (initializedRef.current) return
  51. initializedRef.current = true
  52. try {
  53. const stored = localStorage.getItem(STORAGE_KEY)
  54. const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
  55. if (stored) {
  56. const data: StoredTableData = JSON.parse(stored)
  57. setTables(data.tables || [])
  58. // Restore active table
  59. if (activeId && data.tables) {
  60. const active = data.tables.find(t => t.id === activeId)
  61. if (active) {
  62. restoredActiveIdRef.current = activeId // Mark that we restored a selection
  63. setActiveTableState(active)
  64. // Only set non-empty base URL for remote tables
  65. if (!active.isCurrent && active.url !== window.location.origin) {
  66. apiClient.setBaseUrl(active.url)
  67. }
  68. }
  69. }
  70. }
  71. // Always refresh to ensure current table is available and up-to-date
  72. discoverTables()
  73. } catch (e) {
  74. console.error('Failed to load saved tables:', e)
  75. discoverTables()
  76. }
  77. }, [])
  78. // Save tables to localStorage when they change
  79. useEffect(() => {
  80. if (!initializedRef.current) return
  81. try {
  82. const data: StoredTableData = {
  83. tables,
  84. activeTableId: activeTable?.id || null,
  85. }
  86. localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
  87. if (activeTable) {
  88. localStorage.setItem(ACTIVE_TABLE_KEY, activeTable.id)
  89. } else {
  90. localStorage.removeItem(ACTIVE_TABLE_KEY)
  91. }
  92. } catch (e) {
  93. console.error('Failed to save tables:', e)
  94. }
  95. }, [tables, activeTable])
  96. // Set active table - saves to localStorage and reloads page for clean state
  97. const setActiveTable = useCallback((table: Table) => {
  98. // Save to localStorage before reload
  99. try {
  100. const currentTables = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
  101. const data: StoredTableData = {
  102. tables: currentTables.tables || tables,
  103. activeTableId: table.id,
  104. }
  105. localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
  106. localStorage.setItem(ACTIVE_TABLE_KEY, table.id)
  107. } catch (e) {
  108. console.error('Failed to save table selection:', e)
  109. }
  110. // Update API client base URL
  111. if (table.isCurrent || table.url === window.location.origin) {
  112. apiClient.setBaseUrl('')
  113. } else {
  114. apiClient.setBaseUrl(table.url)
  115. }
  116. // Reload page for clean state (WebSockets, caches, etc.)
  117. window.location.reload()
  118. }, [tables])
  119. // Refresh tables - ensures current table is always available
  120. const discoverTables = useCallback(async () => {
  121. setIsDiscovering(true)
  122. try {
  123. // Always fetch the current table's info
  124. const infoResponse = await fetch('/api/table-info')
  125. if (!infoResponse.ok) {
  126. throw new Error('Failed to fetch table info')
  127. }
  128. const info = await infoResponse.json()
  129. const currentTable: Table = {
  130. id: info.id,
  131. name: info.name,
  132. url: window.location.origin,
  133. version: info.version,
  134. isOnline: true,
  135. isCurrent: true,
  136. }
  137. // Merge with existing tables
  138. setTables(prev => {
  139. // Start with current table
  140. const merged: Table[] = [currentTable]
  141. // Add any other tables (manual additions), mark them for status check
  142. prev.forEach(existing => {
  143. if (existing.id !== currentTable.id && !existing.isCurrent) {
  144. merged.push({ ...existing, isOnline: existing.isOnline ?? false })
  145. }
  146. })
  147. return merged
  148. })
  149. // If no active table AND no restored selection, select the current one
  150. // Use ref to check restored selection because activeTable state may not be updated yet
  151. if (!activeTable && !restoredActiveIdRef.current) {
  152. setActiveTable(currentTable)
  153. } else if (activeTable?.isCurrent) {
  154. // Update active table name if it changed on the backend
  155. setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
  156. }
  157. // Clear the restored ref after first discovery
  158. restoredActiveIdRef.current = null
  159. setLastDiscovery(new Date())
  160. } catch (e) {
  161. console.error('Table refresh failed:', e)
  162. } finally {
  163. setIsDiscovering(false)
  164. }
  165. }, [activeTable, setActiveTable])
  166. // Add a table manually by URL
  167. const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {
  168. try {
  169. // Normalize URL
  170. const normalizedUrl = url.replace(/\/$/, '')
  171. // Check if already exists
  172. if (tables.find(t => t.url === normalizedUrl)) {
  173. return null
  174. }
  175. // Fetch table info from the URL
  176. const response = await fetch(`${normalizedUrl}/api/table-info`)
  177. if (!response.ok) {
  178. throw new Error('Failed to fetch table info')
  179. }
  180. const info = await response.json()
  181. const newTable: Table = {
  182. id: info.id,
  183. name: name || info.name,
  184. url: normalizedUrl,
  185. version: info.version,
  186. isOnline: true,
  187. isCurrent: false,
  188. }
  189. setTables(prev => [...prev, newTable])
  190. return newTable
  191. } catch (e) {
  192. console.error('Failed to add table:', e)
  193. return null
  194. }
  195. }, [tables])
  196. // Remove a table
  197. const removeTable = useCallback((id: string) => {
  198. setTables(prev => prev.filter(t => t.id !== id))
  199. // If removing active table, switch to another
  200. if (activeTable?.id === id) {
  201. const remaining = tables.filter(t => t.id !== id)
  202. if (remaining.length > 0) {
  203. setActiveTable(remaining[0])
  204. } else {
  205. setActiveTableState(null)
  206. apiClient.setBaseUrl('')
  207. }
  208. }
  209. }, [activeTable, tables, setActiveTable])
  210. // Update table name (on the backend)
  211. const updateTableName = useCallback(async (id: string, name: string) => {
  212. const table = tables.find(t => t.id === id)
  213. if (!table) return
  214. try {
  215. const baseUrl = table.isCurrent ? '' : table.url
  216. const response = await fetch(`${baseUrl}/api/table-info`, {
  217. method: 'PATCH',
  218. headers: { 'Content-Type': 'application/json' },
  219. body: JSON.stringify({ name }),
  220. })
  221. if (response.ok) {
  222. setTables(prev =>
  223. prev.map(t => (t.id === id ? { ...t, name } : t))
  224. )
  225. // Update active table if it's the one being renamed
  226. if (activeTable?.id === id) {
  227. setActiveTableState(prev => prev ? { ...prev, name } : null)
  228. }
  229. }
  230. } catch (e) {
  231. console.error('Failed to update table name:', e)
  232. }
  233. }, [tables, activeTable])
  234. // Check if a table is online
  235. const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
  236. try {
  237. const baseUrl = table.isCurrent ? '' : table.url
  238. const response = await fetch(`${baseUrl}/api/table-info`, {
  239. signal: AbortSignal.timeout(3000),
  240. })
  241. const isOnline = response.ok
  242. setTables(prev =>
  243. prev.map(t => (t.id === table.id ? { ...t, isOnline } : t))
  244. )
  245. return isOnline
  246. } catch {
  247. setTables(prev =>
  248. prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
  249. )
  250. return false
  251. }
  252. }, [])
  253. return (
  254. <TableContext.Provider
  255. value={{
  256. tables,
  257. activeTable,
  258. isDiscovering,
  259. lastDiscovery,
  260. setActiveTable,
  261. discoverTables,
  262. addTable,
  263. removeTable,
  264. updateTableName,
  265. refreshTableStatus,
  266. }}
  267. >
  268. {children}
  269. </TableContext.Provider>
  270. )
  271. }
  272. export function useTable() {
  273. const context = useContext(TableContext)
  274. if (!context) {
  275. throw new Error('useTable must be used within a TableProvider')
  276. }
  277. return context
  278. }
  279. // Hook for subscribing to active table changes (for WebSocket reconnection)
  280. export function useActiveTableChange(callback: (table: Table | null) => void) {
  281. const { activeTable } = useTable()
  282. const callbackRef = useRef(callback)
  283. const prevTableRef = useRef<Table | null>(null)
  284. callbackRef.current = callback
  285. useEffect(() => {
  286. // Only call on actual changes, not initial render
  287. if (prevTableRef.current !== null || activeTable !== null) {
  288. if (prevTableRef.current?.id !== activeTable?.id) {
  289. callbackRef.current(activeTable)
  290. }
  291. }
  292. prevTableRef.current = activeTable
  293. }, [activeTable])
  294. }