1
0

TableContext.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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. appName?: string // Application name from settings (e.g., "Dune Weaver")
  14. url: string
  15. host?: string
  16. port?: number
  17. version?: string
  18. isOnline?: boolean
  19. isCurrent?: boolean // True if this is the backend serving the frontend
  20. customLogo?: string // Custom logo filename if set (e.g., "logo_abc123.png")
  21. }
  22. interface TableContextType {
  23. // State
  24. tables: Table[]
  25. activeTable: Table | null
  26. isDiscovering: boolean
  27. lastDiscovery: Date | null
  28. // Actions
  29. setActiveTable: (table: Table) => void
  30. discoverTables: () => Promise<void>
  31. addTable: (url: string, name?: string) => Promise<Table | null>
  32. removeTable: (id: string) => void
  33. updateTableName: (id: string, name: string) => Promise<void>
  34. refreshTableStatus: (table: Table) => Promise<boolean>
  35. }
  36. const TableContext = createContext<TableContextType | null>(null)
  37. const STORAGE_KEY = 'duneweaver_tables'
  38. const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
  39. /**
  40. * Normalize a URL to its origin for comparison purposes.
  41. * This handles port normalization (e.g., :80 for HTTP is stripped).
  42. * Returns the origin or the original string if parsing fails.
  43. */
  44. function normalizeUrlOrigin(url: string): string {
  45. try {
  46. return new URL(url).origin
  47. } catch {
  48. return url
  49. }
  50. }
  51. interface StoredTableData {
  52. tables: Table[]
  53. activeTableId: string | null
  54. }
  55. export function TableProvider({ children }: { children: React.ReactNode }) {
  56. const [tables, setTables] = useState<Table[]>([])
  57. const [activeTable, setActiveTableState] = useState<Table | null>(null)
  58. const [isDiscovering, setIsDiscovering] = useState(false)
  59. const [lastDiscovery, setLastDiscovery] = useState<Date | null>(null)
  60. const initializedRef = useRef(false)
  61. const restoredActiveIdRef = useRef<string | null>(null) // Track restored selection
  62. // Load saved tables from localStorage on mount
  63. useEffect(() => {
  64. if (initializedRef.current) return
  65. initializedRef.current = true
  66. try {
  67. const stored = localStorage.getItem(STORAGE_KEY)
  68. const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
  69. if (stored) {
  70. const data: StoredTableData = JSON.parse(stored)
  71. setTables(data.tables || [])
  72. // Restore active table
  73. if (activeId && data.tables) {
  74. const active = data.tables.find(t => t.id === activeId)
  75. if (active) {
  76. restoredActiveIdRef.current = activeId // Mark that we restored a selection
  77. setActiveTableState(active)
  78. // Set base URL for remote tables (tables not on the current origin)
  79. // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
  80. // Note: apiClient pre-initializes from localStorage, but this ensures consistency
  81. if (normalizeUrlOrigin(active.url) !== window.location.origin) {
  82. apiClient.setBaseUrl(active.url)
  83. }
  84. }
  85. }
  86. }
  87. // Always refresh to ensure current table is available and up-to-date
  88. discoverTables()
  89. } catch (e) {
  90. console.error('Failed to load saved tables:', e)
  91. discoverTables()
  92. }
  93. }, [])
  94. // Save tables to localStorage when they change
  95. useEffect(() => {
  96. if (!initializedRef.current) return
  97. try {
  98. const data: StoredTableData = {
  99. tables,
  100. activeTableId: activeTable?.id || null,
  101. }
  102. localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
  103. if (activeTable) {
  104. localStorage.setItem(ACTIVE_TABLE_KEY, activeTable.id)
  105. } else {
  106. localStorage.removeItem(ACTIVE_TABLE_KEY)
  107. }
  108. } catch (e) {
  109. console.error('Failed to save tables:', e)
  110. }
  111. }, [tables, activeTable])
  112. // Set active table - saves to localStorage and reloads page for clean state
  113. const setActiveTable = useCallback((table: Table) => {
  114. // Save to localStorage before reload
  115. try {
  116. const currentTables = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
  117. const data: StoredTableData = {
  118. tables: currentTables.tables || tables,
  119. activeTableId: table.id,
  120. }
  121. localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
  122. localStorage.setItem(ACTIVE_TABLE_KEY, table.id)
  123. } catch (e) {
  124. console.error('Failed to save table selection:', e)
  125. }
  126. // Update API client base URL
  127. // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
  128. if (normalizeUrlOrigin(table.url) === window.location.origin) {
  129. apiClient.setBaseUrl('')
  130. } else {
  131. apiClient.setBaseUrl(table.url)
  132. }
  133. // Reload page for clean state (WebSockets, caches, etc.)
  134. window.location.reload()
  135. }, [tables])
  136. // Refresh tables - ensures current table is always available
  137. const discoverTables = useCallback(async () => {
  138. setIsDiscovering(true)
  139. try {
  140. // Fetch table info, settings, and known tables in parallel
  141. const [infoResponse, settingsResponse, knownTablesResponse] = await Promise.all([
  142. fetch('/api/table-info'),
  143. fetch('/api/settings').catch(() => null),
  144. fetch('/api/known-tables').catch(() => null),
  145. ])
  146. if (!infoResponse.ok) {
  147. throw new Error('Failed to fetch table info')
  148. }
  149. const info = await infoResponse.json()
  150. const settings = settingsResponse?.ok ? await settingsResponse.json() : null
  151. const knownTablesData = knownTablesResponse?.ok ? await knownTablesResponse.json() : null
  152. const knownTables: Array<{ id: string; name: string; url: string; host?: string; port?: number; version?: string }> = knownTablesData?.tables || []
  153. const currentTable: Table = {
  154. id: info.id,
  155. name: info.name,
  156. url: window.location.origin,
  157. version: info.version,
  158. isOnline: true,
  159. isCurrent: true,
  160. customLogo: settings?.app?.custom_logo || undefined,
  161. }
  162. // Merge current table with known tables from backend
  163. setTables(() => {
  164. // Start with current table
  165. const merged: Table[] = [currentTable]
  166. // Add known tables from backend (these are persisted remote tables)
  167. knownTables.forEach(known => {
  168. if (known.id !== currentTable.id) {
  169. merged.push({
  170. id: known.id,
  171. name: known.name,
  172. url: known.url,
  173. host: known.host,
  174. port: known.port,
  175. version: known.version,
  176. isOnline: false, // Will be updated by background refresh
  177. isCurrent: false,
  178. })
  179. }
  180. })
  181. return merged
  182. })
  183. // If no active table AND no restored selection, select the current one
  184. // Use ref to check restored selection because activeTable state may not be updated yet
  185. if (!activeTable && !restoredActiveIdRef.current) {
  186. // For initial selection of current table, just update state without reload
  187. // Reload is only needed when switching between DIFFERENT tables
  188. setActiveTableState(currentTable)
  189. // Save to localStorage so it persists
  190. try {
  191. const data: StoredTableData = {
  192. tables: [currentTable],
  193. activeTableId: currentTable.id,
  194. }
  195. localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
  196. localStorage.setItem(ACTIVE_TABLE_KEY, currentTable.id)
  197. } catch (e) {
  198. console.error('Failed to save initial table selection:', e)
  199. }
  200. } else if (activeTable?.isCurrent) {
  201. // Update active table name if it changed on the backend
  202. setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
  203. }
  204. // Clear the restored ref after first discovery
  205. restoredActiveIdRef.current = null
  206. setLastDiscovery(new Date())
  207. // Refresh remote tables in the background to get their customLogo
  208. // Use setTimeout to not block the main discovery flow
  209. setTimeout(() => {
  210. setTables(currentTables => {
  211. const remoteTables = currentTables.filter(t => !t.isCurrent)
  212. remoteTables.forEach(async (table) => {
  213. try {
  214. const [infoResponse, settingsResponse] = await Promise.all([
  215. fetch(`${table.url}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
  216. fetch(`${table.url}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
  217. ])
  218. const isOnline = infoResponse.ok
  219. const settings = settingsResponse?.ok ? await settingsResponse.json() : null
  220. const customLogo = settings?.app?.custom_logo || undefined
  221. setTables(prev =>
  222. prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
  223. )
  224. } catch {
  225. setTables(prev =>
  226. prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
  227. )
  228. }
  229. })
  230. return currentTables // Return unchanged for now, updates happen in the async callbacks
  231. })
  232. }, 100)
  233. } catch (e) {
  234. console.error('Table refresh failed:', e)
  235. } finally {
  236. setIsDiscovering(false)
  237. }
  238. }, [activeTable]) // Only depends on activeTable for checking if we need to update name
  239. // Add a table manually by URL
  240. const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {
  241. try {
  242. // Normalize URL
  243. const normalizedUrl = url.replace(/\/$/, '')
  244. // Check if already exists
  245. if (tables.find(t => t.url === normalizedUrl)) {
  246. return null
  247. }
  248. // Fetch table info and settings in parallel
  249. const [infoResponse, settingsResponse] = await Promise.all([
  250. fetch(`${normalizedUrl}/api/table-info`),
  251. fetch(`${normalizedUrl}/api/settings`).catch(() => null),
  252. ])
  253. if (!infoResponse.ok) {
  254. throw new Error('Failed to fetch table info')
  255. }
  256. const info = await infoResponse.json()
  257. const settings = settingsResponse?.ok ? await settingsResponse.json() : null
  258. const newTable: Table = {
  259. id: info.id,
  260. name: name || info.name,
  261. url: normalizedUrl,
  262. version: info.version,
  263. isOnline: true,
  264. isCurrent: false,
  265. customLogo: settings?.app?.custom_logo || undefined,
  266. }
  267. // Persist to backend
  268. try {
  269. const hostname = new URL(normalizedUrl).hostname
  270. await fetch('/api/known-tables', {
  271. method: 'POST',
  272. headers: { 'Content-Type': 'application/json' },
  273. body: JSON.stringify({
  274. id: newTable.id,
  275. name: newTable.name,
  276. url: newTable.url,
  277. host: hostname,
  278. version: newTable.version,
  279. }),
  280. })
  281. } catch (e) {
  282. console.error('Failed to persist table to backend:', e)
  283. // Continue anyway - table will still work for this session
  284. }
  285. setTables(prev => [...prev, newTable])
  286. return newTable
  287. } catch (e) {
  288. console.error('Failed to add table:', e)
  289. return null
  290. }
  291. }, [tables])
  292. // Remove a table
  293. const removeTable = useCallback(async (id: string) => {
  294. // Remove from backend
  295. try {
  296. await fetch(`/api/known-tables/${id}`, { method: 'DELETE' })
  297. } catch (e) {
  298. console.error('Failed to remove table from backend:', e)
  299. // Continue anyway - remove from local state
  300. }
  301. setTables(prev => prev.filter(t => t.id !== id))
  302. // If removing active table, switch to another
  303. if (activeTable?.id === id) {
  304. const remaining = tables.filter(t => t.id !== id)
  305. if (remaining.length > 0) {
  306. setActiveTable(remaining[0])
  307. } else {
  308. setActiveTableState(null)
  309. apiClient.setBaseUrl('')
  310. }
  311. }
  312. }, [activeTable, tables, setActiveTable])
  313. // Update table name (on the backend)
  314. const updateTableName = useCallback(async (id: string, name: string) => {
  315. const table = tables.find(t => t.id === id)
  316. if (!table) return
  317. try {
  318. const baseUrl = table.isCurrent ? '' : table.url
  319. const response = await fetch(`${baseUrl}/api/table-info`, {
  320. method: 'PATCH',
  321. headers: { 'Content-Type': 'application/json' },
  322. body: JSON.stringify({ name }),
  323. })
  324. if (response.ok) {
  325. // Also update the known table name in the current backend (for remote tables)
  326. if (!table.isCurrent) {
  327. try {
  328. await fetch(`/api/known-tables/${id}`, {
  329. method: 'PATCH',
  330. headers: { 'Content-Type': 'application/json' },
  331. body: JSON.stringify({ name }),
  332. })
  333. } catch (e) {
  334. console.error('Failed to update known table name:', e)
  335. }
  336. }
  337. setTables(prev =>
  338. prev.map(t => (t.id === id ? { ...t, name } : t))
  339. )
  340. // Update active table if it's the one being renamed
  341. if (activeTable?.id === id) {
  342. setActiveTableState(prev => prev ? { ...prev, name } : null)
  343. }
  344. }
  345. } catch (e) {
  346. console.error('Failed to update table name:', e)
  347. }
  348. }, [tables, activeTable])
  349. // Check if a table is online and update its info (including custom logo)
  350. const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
  351. try {
  352. const baseUrl = table.isCurrent ? '' : table.url
  353. // Fetch table info and settings in parallel
  354. const [infoResponse, settingsResponse] = await Promise.all([
  355. fetch(`${baseUrl}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
  356. fetch(`${baseUrl}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
  357. ])
  358. const isOnline = infoResponse.ok
  359. const settings = settingsResponse?.ok ? await settingsResponse.json() : null
  360. const customLogo = settings?.app?.custom_logo || undefined
  361. setTables(prev =>
  362. prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
  363. )
  364. return isOnline
  365. } catch {
  366. setTables(prev =>
  367. prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
  368. )
  369. return false
  370. }
  371. }, [])
  372. return (
  373. <TableContext.Provider
  374. value={{
  375. tables,
  376. activeTable,
  377. isDiscovering,
  378. lastDiscovery,
  379. setActiveTable,
  380. discoverTables,
  381. addTable,
  382. removeTable,
  383. updateTableName,
  384. refreshTableStatus,
  385. }}
  386. >
  387. {children}
  388. </TableContext.Provider>
  389. )
  390. }
  391. export function useTable() {
  392. const context = useContext(TableContext)
  393. if (!context) {
  394. throw new Error('useTable must be used within a TableProvider')
  395. }
  396. return context
  397. }
  398. // Hook for subscribing to active table changes (for WebSocket reconnection)
  399. export function useActiveTableChange(callback: (table: Table | null) => void) {
  400. const { activeTable } = useTable()
  401. const callbackRef = useRef(callback)
  402. const prevTableRef = useRef<Table | null>(null)
  403. callbackRef.current = callback
  404. useEffect(() => {
  405. // Only call on actual changes, not initial render
  406. if (prevTableRef.current !== null || activeTable !== null) {
  407. if (prevTableRef.current?.id !== activeTable?.id) {
  408. callbackRef.current(activeTable)
  409. }
  410. }
  411. prevTableRef.current = activeTable
  412. }, [activeTable])
  413. }