From 139932dcbf6952d35263e8f4685fef3287a2a4a1 Mon Sep 17 00:00:00 2001 From: Steve W Date: Sun, 12 Apr 2026 01:49:34 +0000 Subject: [PATCH] feat: use incremental polling for queue refresh --- src/App.css | 5 +++ src/App.jsx | 120 ++++++++++++++++++++-------------------------------- 2 files changed, 51 insertions(+), 74 deletions(-) diff --git a/src/App.css b/src/App.css index 69ff659..42bd416 100644 --- a/src/App.css +++ b/src/App.css @@ -59,6 +59,11 @@ h2 { color: #cbd5e1; } +.refreshing-indicator { + color: #93c5fd; + font-size: 0.9rem; +} + .banner { padding: 0.9rem 1rem; margin-bottom: 1rem; diff --git a/src/App.jsx b/src/App.jsx index 9125536..e3945ef 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,10 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import './App.css' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://app-01:8080' -const WS_URL = import.meta.env.VITE_WS_URL || '' const POLL_INTERVAL_MS = 10000 -const RECONNECT_DELAY_MS = 5000 const statusColors = { queued: 'status-queued', @@ -68,95 +66,68 @@ function DetailRow({ label, value, mono = false, preserve = false }) { function App() { const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState('') const [sort, setSort] = useState({ key: 'created_at', direction: 'desc' }) const [filters, setFilters] = useState(defaultFilters) const [selectedId, setSelectedId] = useState('') - const [transport, setTransport] = useState(WS_URL ? 'connecting websocket…' : 'polling') + const [transport] = useState('incremental polling') + const lastSuccessRef = useRef('') + + const mergeItems = useCallback((incomingItems) => { + setItems((current) => { + if (!incomingItems.length) return current + const next = new Map(current.map((item) => [item.id, item])) + incomingItems.forEach((item) => next.set(item.id, item)) + return Array.from(next.values()) + }) + }, []) + + const refreshTimestamp = useCallback((sourceItems) => { + const timestamps = sourceItems.map((item) => item.updated_at).filter(Boolean).sort() + if (timestamps.length) { + lastSuccessRef.current = timestamps[timestamps.length - 1] + } + }, []) + + const load = useCallback(async (incremental = false) => { + if (incremental) setRefreshing(true) - const load = useCallback(async () => { try { - const response = await fetch(`${API_BASE_URL}/work`) + const url = new URL(`${API_BASE_URL}/work`) + if (incremental && lastSuccessRef.current) { + url.searchParams.set('since', lastSuccessRef.current) + } + + const response = await fetch(url) if (!response.ok) throw new Error(`Request failed with ${response.status}`) const data = await response.json() - setItems(data) + + if (incremental && lastSuccessRef.current) { + mergeItems(data) + } else { + setItems(data) + } + + refreshTimestamp(data.length ? data : items) setError('') setSelectedId((current) => { - if (current && data.some((item) => item.id === current)) return current - return data[0]?.id ?? '' + const pool = data.length && incremental ? [...items, ...data] : data.length ? data : items + if (current && pool.some((item) => item.id === current)) return current + return pool[0]?.id ?? '' }) } catch (err) { setError(err.message || 'Failed to load queue') } finally { setLoading(false) + setRefreshing(false) } - }, []) + }, [items, mergeItems, refreshTimestamp]) useEffect(() => { - let active = true - let socket - let pollTimer - let reconnectTimer - - const startPolling = () => { - setTransport('polling') - load() - pollTimer = window.setInterval(load, POLL_INTERVAL_MS) - } - - const stopPolling = () => { - if (pollTimer) window.clearInterval(pollTimer) - } - - const connectWebSocket = () => { - if (!WS_URL || !active) { - startPolling() - return - } - - try { - socket = new WebSocket(WS_URL) - } catch { - startPolling() - return - } - - socket.onopen = () => { - if (!active) return - stopPolling() - setTransport('websocket') - load() - } - - socket.onmessage = () => { - if (!active) return - load() - } - - socket.onerror = () => { - if (!active) return - setTransport('polling') - } - - socket.onclose = () => { - if (!active) return - startPolling() - reconnectTimer = window.setTimeout(() => { - stopPolling() - connectWebSocket() - }, RECONNECT_DELAY_MS) - } - } - - load() - connectWebSocket() - - return () => { - active = false - stopPolling() - if (reconnectTimer) window.clearTimeout(reconnectTimer) - if (socket && socket.readyState < 2) socket.close() - } + load(false) + const timer = window.setInterval(() => load(true), POLL_INTERVAL_MS) + return () => window.clearInterval(timer) }, [load]) const filterOptions = useMemo( @@ -214,6 +185,7 @@ function App() { {filteredItems.length} visible items {items.length} total items Updates via {transport} + {refreshing && Refreshing…}