dataflow/ui/src/App.jsx
2026-04-25 10:28:32 -04:00

267 lines
11 KiB
JavaScript

import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'
import { api, setCredentials, clearCredentials } from './api'
import Login from './pages/Login'
import Sources from './pages/Sources'
import Import from './pages/Import'
import Rules from './pages/Rules'
import Mappings from './pages/Mappings'
import Records from './pages/Records'
import Log from './pages/Log'
import Pivot from './pages/Pivot'
import Remap from './pages/Remap'
import Stacks from './pages/Stacks'
const NAV = [
{ to: '/sources', label: 'Sources' },
{ to: '/import', label: 'Import' },
{ to: '/rules', label: 'Rules' },
{ to: '/mappings', label: 'Mappings' },
{ to: '/remap', label: 'Remap' },
{ to: '/records', label: 'Records' },
{ to: '/pivot', label: 'Pivot' },
{ to: '/stacks', label: 'Stacks' },
{ to: '/log', label: 'Log' },
]
export default function App() {
const [authed, setAuthed] = useState(false)
const [loginUser, setLoginUser] = useState('')
const [sources, setSources] = useState([])
const [source, setSource] = useState(() => localStorage.getItem('selectedSource') || '')
const [sidebarOpen, setSidebarOpen] = useState(false)
// Sets of names whose dfv view is out of sync with current definitions
const [staleSources, setStaleSources] = useState(new Set())
const [staleStacks, setStaleStacks] = useState(new Set())
const [reprocessSources, setReprocessSources] = useState(new Set())
const [generating, setGenerating] = useState({}) // { 'source:name': true }
async function handleLogin(user, pass) {
setCredentials(user, pass)
await api.getSources().then(s => {
sessionStorage.setItem('df_user', user)
sessionStorage.setItem('df_pass', pass)
setSources(s)
if (!source && s.length > 0) setSource(s[0].name)
setAuthed(true)
setLoginUser(user)
})
}
function handleLogout() {
clearCredentials()
sessionStorage.removeItem('df_user')
sessionStorage.removeItem('df_pass')
setAuthed(false)
setLoginUser('')
setSources([])
setStaleSources(new Set())
setStaleStacks(new Set())
setReprocessSources(new Set())
}
// Load initial stale state from DB once on login
useEffect(() => {
if (!authed) return
api.getStatus().then(s => {
setStaleSources(new Set((s.stale_sources || []).map(x => x.name)))
setStaleStacks(new Set((s.stale_stacks || []).map(x => x.name)))
}).catch(() => {})
}, [authed])
function markSourceStale(name) {
setStaleSources(prev => new Set([...prev, name]))
}
function markNeedsReprocess(name) {
setReprocessSources(prev => new Set([...prev, name]))
}
async function handleReprocessSource(name) {
setGenerating(g => ({ ...g, [`rp:${name}`]: true }))
try {
await api.reprocess(name)
setReprocessSources(prev => { const n = new Set(prev); n.delete(name); return n })
} catch (e) { alert(e.message) }
finally { setGenerating(g => { const n = { ...g }; delete n[`rp:${name}`]; return n }) }
}
function markStackStale(name) {
setStaleStacks(prev => new Set([...prev, name]))
}
function clearStackStale(name) {
setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleGenerateSource(name) {
setGenerating(g => ({ ...g, [`src:${name}`]: true }))
try {
await api.generateView(name)
setStaleSources(prev => { const n = new Set(prev); n.delete(name); return n })
} catch (e) { alert(e.message) }
finally { setGenerating(g => { const n = { ...g }; delete n[`src:${name}`]; return n }) }
}
async function handleGenerateStack(name) {
setGenerating(g => ({ ...g, [`stk:${name}`]: true }))
try {
await api.generateStackView(name)
setStaleStacks(prev => { const n = new Set(prev); n.delete(name); return n })
} catch (e) { alert(e.message) }
finally { setGenerating(g => { const n = { ...g }; delete n[`stk:${name}`]; return n }) }
}
// On mount, restore session if credentials are saved
useEffect(() => {
const user = sessionStorage.getItem('df_user')
const pass = sessionStorage.getItem('df_pass')
if (user && pass) handleLogin(user, pass).catch(() => handleLogout())
}, [])
useEffect(() => {
if (source) localStorage.setItem('selectedSource', source)
}, [source])
if (!authed) return <Login onLogin={handleLogin} />
const sidebar = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-gray-600 leading-none" title="Close"></button>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-gray-400">{loginUser}</span>
<button onClick={handleLogout} className="text-xs text-gray-400 hover:text-red-500" title="Sign out">Sign out</button>
</div>
</div>
{/* Source selector */}
<div className="px-3 py-3 border-b border-gray-200">
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-gray-500">Source</label>
<NavLink to="/sources?new=1" className="text-xs text-blue-400 hover:text-blue-600 leading-none" title="New source" onClick={() => setSidebarOpen(false)}>+</NavLink>
</div>
<select
className="w-full text-sm border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:border-blue-400"
value={source}
onChange={e => setSource(e.target.value)}
>
{sources.length === 0 && <option value=""></option>}
{sources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</select>
</div>
{/* Nav */}
<nav className="flex-1 py-2">
{NAV.map(({ to, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`block px-4 py-2 text-sm ${isActive
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50'}`
}
>
{label}
</NavLink>
))}
</nav>
</div>
)
return (
<BrowserRouter>
<div className="flex h-screen bg-gray-50">
{/* Mobile overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-20 bg-black/30 md:hidden" onClick={() => setSidebarOpen(false)} />
)}
{/* Sidebar — fixed on mobile, static on desktop */}
<div className={`
fixed inset-y-0 left-0 z-30 w-44 bg-white border-r border-gray-200 transform transition-transform duration-200
md:static md:translate-x-0 md:z-auto md:transition-none
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
{sidebar}
</div>
{/* Main */}
<div className="flex-1 overflow-auto flex flex-col min-w-0">
{/* Mobile top bar */}
<div className="md:hidden flex items-center px-3 py-2 bg-white border-b border-gray-200">
<button onClick={() => setSidebarOpen(true)} className="text-gray-500 hover:text-gray-700 mr-3 text-lg leading-none"></button>
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
</div>
{(staleSources.size > 0 || staleStacks.size > 0) && (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">View out of sync:</span>
{[...staleSources].map(name => (
<span key={name} className="flex items-center gap-1">
{name}
<button
onClick={() => handleGenerateSource(name)}
disabled={generating[`src:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
>
{generating[`src:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>}
{[...staleStacks].map(name => (
<span key={name} className="flex items-center gap-1">
stack: {name}
<button
onClick={() => handleGenerateStack(name)}
disabled={generating[`stk:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium"
>
{generating[`stk:${name}`] ? '…' : 'Generate'}
</button>
</span>
))}
</div>
)}
{reprocessSources.size > 0 && (
<div className="bg-blue-50 border-b border-blue-200 px-4 py-1.5 text-xs text-blue-800 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">Mappings updated:</span>
{[...reprocessSources].map(name => (
<span key={name} className="flex items-center gap-1">
{name}
<button
onClick={() => handleReprocessSource(name)}
disabled={generating[`rp:${name}`]}
className="px-1.5 py-0.5 rounded bg-blue-200 hover:bg-blue-300 disabled:opacity-50 font-medium"
>
{generating[`rp:${name}`] ? '…' : 'Reprocess'}
</button>
</span>
))}
</div>
)}
<div className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Navigate to="/sources" replace />} />
<Route path="/sources" element={<Sources source={source} sources={sources} setSources={setSources} setSource={setSource} />} />
<Route path="/import" element={<Import source={source} />} />
<Route path="/rules" element={<Rules source={source} onStale={markSourceStale} />} />
<Route path="/mappings" element={<Mappings source={source} onNeedsReprocess={markNeedsReprocess} />} />
<Route path="/remap" element={<Remap />} />
<Route path="/records" element={<Records source={source} />} />
<Route path="/pivot" element={<Pivot source={source} />} />
<Route path="/stacks" element={<Stacks sources={sources} onStackStale={markStackStale} onStackViewGenerated={clearStackStale} />} />
<Route path="/log" element={<Log />} />
</Routes>
</div>
</div>
</div>
</BrowserRouter>
)
}