Harden lease lifecycle durability
This commit is contained in:
@@ -16,4 +16,10 @@ describe('UI API client',()=>{
|
||||
await expect(api.upload('report')).resolves.toBe('a'.repeat(64))
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
it('submits username and password to the browser login endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(null,{status:204}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.login('operator','not stored in the browser')
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/session',expect.objectContaining({method:'POST',body:JSON.stringify({username:'operator',password:'not stored in the browser'})}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,6 @@ function sessionExpired(r:Response){if(r.status===401)window.dispatchEvent(new E
|
||||
async function request<T>(path:string, init?:RequestInit):Promise<T>{const r=await fetch(path,{credentials:'same-origin',headers:{'Content-Type':'application/json',...init?.headers},...init});if(!r.ok){sessionExpired(r);throw new Error(await r.text())}return r.json() as Promise<T>}
|
||||
async function text(path:string){const r=await fetch(path);if(!r.ok)throw new Error(await r.text());return r.text()}
|
||||
async function upload(body:string){const r=await fetch('/v1/artifacts',{method:'POST',headers:{'Content-Type':'text/markdown'},body});if(!r.ok)throw new Error(await r.text());return (await r.json() as {ref:string}).ref}
|
||||
async function login(token:string){const r=await fetch('/v1/ui/session',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({token})});if(!r.ok)throw new Error(await r.text())}
|
||||
async function login(username:string,password:string){const r=await fetch('/v1/ui/session',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})});if(!r.ok)throw new Error(await r.text())}
|
||||
async function logout(){const r=await fetch('/v1/ui/session',{method:'DELETE',credentials:'same-origin'});if(!r.ok)throw new Error(await r.text())}
|
||||
export const api={login,logout,overview:()=>request<Overview>('/v1/ui/overview'),detail:(id:string)=>request<Detail>('/v1/ui/tasks/'+id),artifact:(ref:string)=>text('/v1/ui/artifacts/'+ref),upload,create:(body:unknown)=>request('/v1/ui/tasks',{method:'POST',body:JSON.stringify(body)}),action:(id:string,action:string,body={})=>request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`,{method:'POST',body:JSON.stringify(body)})}
|
||||
|
||||
+8
-4
@@ -13,14 +13,18 @@ const label:Record<TaskState,string>={queued:'Queued',leased:'In session',blocke
|
||||
const blockLabel:Record<BlockReason,string>={lease_failure:'Lease failure',worker_offline:'Worker offline',lease_expired:'Expired lease',approval:'Approval needed',handoff_validation:'Handoff validation',operator_block:'Operator block',system_error:'System error',unknown:'Unknown'}
|
||||
const actionLabel:Record<string,string>={handoff:'Request handoff',release:'Release task',complete:'Complete task',block:'Mark blocked'}
|
||||
const date=(value?:string)=>value?new Date(value).toLocaleString():'—'
|
||||
// Go's zero time can still arrive as "0001-…" despite an omitempty tag on a
|
||||
// value struct. It means this legacy task has no recorded timestamp, not
|
||||
// that it was actually blocked in year one.
|
||||
const recordedTime=(value?:string)=>{if(!value)return undefined;const parsed=new Date(value);return Number.isNaN(parsed.getTime())||parsed.getUTCFullYear()<=1?undefined:value}
|
||||
|
||||
function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=useQueryClient(),input=useRef<HTMLInputElement>(null),[term,setTerm]=useState('');useEffect(()=>{input.current?.focus()},[]);const choose=(id:string)=>{if(id==='board')nav('/');if(id==='workers')nav('/workers');if(id==='new'){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(id==='refresh')qc.invalidateQueries();close()};const commands=[['board','Go to dispatch board','G B'],['new','Create a new task','N'],['workers','View worker pool','G W'],['refresh','Refresh live data','R']] as const;const matches=commands.filter(c=>c[1].toLowerCase().includes(term.toLowerCase()));return <div className="palette-backdrop" onMouseDown={close}><section className="palette" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e=>e.stopPropagation()}><input ref={input} value={term} onChange={e=>setTerm(e.target.value)} onKeyDown={e=>{if(e.key==='Escape')close();if(e.key==='Enter'&&matches[0])choose(matches[0][0])}} placeholder="Find a command…" aria-label="Find a command"/><div className="palette-list">{matches.map(([id,name,key])=><button key={id} className="palette-command" onClick={()=>choose(id)}><span>{name}</span><kbd>{key}</kbd></button>)}{!matches.length&&<p className="palette-empty">No matching command.</p>}</div><p className="palette-foot"><kbd>↵</kbd> run <kbd>esc</kbd> close</p></section></div>}
|
||||
|
||||
function Shell({children,onLogout}:{children:React.ReactNode;onLogout:()=>void}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false),[accountOpen,setAccountOpen]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions?.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{const editable=e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!editable){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(e.key==='r'&&!e.metaKey&&!e.ctrlKey&&!editable)overview.refetch()};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav,overview]);return <div className="app-shell" data-app="orchestra"><aside className="rail"><Link className="mark" to="/" aria-label="Orchestra home"><i className="branch-mark"/><span>OR</span></Link><nav className="rail-nav" aria-label="Primary navigation"><NavLink end className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/"><span>Board</span></NavLink><NavLink className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/workers"><span>Workers</span></NavLink></nav><span className="rail-footer">v0.1</span></aside><header className="topbar"><div className="topbar-title">Orchestra <small>{where.pathname==='/'?'dispatch board':where.pathname==='/workers'?'worker pool':'task record'}</small></div><button className="command" onClick={()=>setPalette(true)} aria-label="Open command palette"><span>Command</span><kbd>⌘ K</kbd></button><div className="readout"><span>SESSIONS <b>{sessions}</b></span><span>SYNC <b>5s</b></span></div><div className="account"><button className="account-button" onClick={()=>setAccountOpen(v=>!v)} aria-expanded={accountOpen}>Signed in <span className="status-dot"/></button>{accountOpen&&<div className="account-menu"><span>Browser session active</span><button onClick={onLogout}>Sign out</button></div>}</div></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
|
||||
|
||||
function taskExplanation(task:Task,overview:Overview){const session=(overview.sessions??[]).find(s=>s.capture?.task_id===task.id);if(task.state==='queued'){const online=(overview.workers??[]).filter(w=>w.online);return online.length?`Waiting for a worker to lease it · ${online.length} worker${online.length===1?'':'s'} online`:'No registered worker is currently reachable'}if(task.state==='leased'){if(session?.pending_approval)return 'Waiting for an operator approval';if(task.lease&&new Date(task.lease.until)<new Date())return 'Lease has expired; waiting for reconciliation';if(session?.blocker)return session.blocker;return session?.capture?'Harness session is active':'Lease is active; live capture is unavailable'}if(task.state==='blocked'){const evidence=task.last_session;const observed=evidence?.captured_at||evidence?.checked_at;const suffix=evidence?` · ${evidence.source||'unknown'} observed ${date(observed)}`:'';return (task.blocker||'Unknown — legacy record has no retained blocker or pane evidence')+suffix}if(task.state==='failed')return 'The task needs review before it can be retried';return 'Terminal task record'}
|
||||
function TaskCard({task,overview}:{task:Task;overview:Overview}){return <Link className="card" to={'/tasks/'+task.id}><b>{task.title||task.id}</b><small><span>{task.project}</span><span className="machine">{task.id.slice(-5)}</span></small><em>{taskExplanation(task,overview)}</em></Link>}
|
||||
function Board({tasks,overview,empty}:{tasks:Task[];overview:Overview;empty:string}){const blocked=tasks.filter(t=>t.state==='blocked').sort((a,b)=>new Date(a.blocked_at||0).getTime()-new Date(b.blocked_at||0).getTime());const groups=new Map<BlockReason,Task[]>;for(const task of blocked){const reason=task.block_reason||'unknown';groups.set(reason,[...(groups.get(reason)||[]),task])}if(!tasks.length)return <section className="empty-panel"><i className="branch-mark"/><h2>No matching work</h2><p>{empty}</p></section>;const boardStates=states.filter(state=>tasks.some(t=>t.state===state));return <><div className="board">{boardStates.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><TaskCard task={t} overview={overview} key={t.id}/>)}</section>})}</div>{blocked.length>0&&<section className="blocked-groups" aria-label="Blocked work by diagnosis"><div className="section-title"><div><h2>Needs attention</h2><p>Blocked work is grouped by diagnosis and ordered oldest first.</p></div><span className="machine">{blocked.length} blocked</span></div><div className="blocked-grid">{[...groups.entries()].map(([reason,group])=><section key={reason}><div className="lane-head"><span>{blockLabel[reason]}</span><span className="lane-count">{group.length}</span></div>{group.map(task=><TaskCard task={task} overview={overview} key={task.id}/>)}</section>)}</div></section>}</>}
|
||||
function TaskCard({task,overview,showBlockedAt=false}:{task:Task;overview:Overview;showBlockedAt?:boolean}){const blockedAt=recordedTime(task.blocked_at);return <Link className="card" to={'/tasks/'+task.id}><b>{task.title||task.id}</b><small><span>{task.project}</span><span className="machine">{task.id.slice(-5)}</span></small><em>{taskExplanation(task,overview)}</em>{showBlockedAt&&blockedAt&&<time className="card-age" dateTime={blockedAt}>Blocked since {date(blockedAt)}</time>}</Link>}
|
||||
function Board({tasks,overview,empty}:{tasks:Task[];overview:Overview;empty:string}){const blocked=tasks.filter(t=>t.state==='blocked').sort((a,b)=>new Date(a.blocked_at||0).getTime()-new Date(b.blocked_at||0).getTime());const groups=new Map<BlockReason,Task[]>;for(const task of blocked){const reason=task.block_reason||'unknown';groups.set(reason,[...(groups.get(reason)||[]),task])}if(!tasks.length)return <section className="empty-panel"><i className="branch-mark"/><h2>No matching work</h2><p>{empty}</p></section>;const boardStates=states.filter(state=>tasks.some(t=>t.state===state));return <><div className="board">{boardStates.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><TaskCard task={t} overview={overview} key={t.id}/>)}</section>})}</div>{blocked.length>0&&<section className="blocked-groups" aria-label="Blocked work by diagnosis"><div className="section-title"><div><h2>Needs attention</h2><p>Blocked work is grouped by diagnosis and ordered oldest first.</p></div><span className="machine">{blocked.length} blocked</span></div><div className="blocked-grid">{[...groups.entries()].map(([reason,group])=><section key={reason}><div className="lane-head"><span>{blockLabel[reason]}</span><span className="lane-count">{group.length}</span></div>{group.map(task=><TaskCard task={task} overview={overview} showBlockedAt key={task.id}/>)}</section>)}</div></section>}</>}
|
||||
|
||||
function OverviewPage(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000}),[createOpen,setCreateOpen]=useState(false),[view,setView]=useState<'active'|'history'|'all'>('active'),[project,setProject]=useState(''),[search,setSearch]=useState('');useEffect(()=>{const open=()=>setCreateOpen(true);window.addEventListener('orchestra:new-task',open);return()=>window.removeEventListener('orchestra:new-task',open)},[]);if(q.isLoading)return <main className="page loading"><p className="eyebrow">Dispatch board</p><p>Fetching queue state from the coordinator…</p></main>;if(q.error)return <main className="page error" role="alert">Queue unavailable: {String(q.error)}</main>;const d=q.data!,sessions=d.sessions??[],active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(sessions.filter(s=>s.pending_approval).map(s=>s.capture?.task_id)),projects=[...new Set(d.tasks.map(t=>t.project).filter(Boolean))].sort(),term=search.trim().toLowerCase(),filtered=d.tasks.filter(t=>(!project||t.project===project)&&(!term||[t.id,t.title,t.description,t.project,t.blocker].filter(Boolean).join(' ').toLowerCase().includes(term))).filter(t=>view==='all'||view==='active'?activeStates.includes(t.state):!activeStates.includes(t.state)),history=d.tasks.filter(t=>!activeStates.includes(t.state)).length,empty=view==='active'?'There is no live work right now. Completed and failed records are available in History.':view==='history'?'No completed or failed records match these filters.':'No task records match these filters.';return <main className="page"><div className="page-heading"><div><p className="eyebrow">Agent dispatch</p><h1>Keep the work moving.</h1></div><div className="heading-actions"><p>Live task state across every connected harness. The board refreshes every five seconds.</p><button onClick={()=>setCreateOpen(true)}>New task <kbd>N</kbd></button></div></div><div className="status-strip" aria-label="Queue summary"><div><span>Tasks</span><b>{d.tasks.length}</b></div><div><span>In session</span><b>{active}</b></div><div className={attention?'attention':''}><span>Needs attention</span><b>{attention}</b></div><div className={approvals.size?'attention':''}><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><div><h2>Task flow</h2><p>{view==='active'?'Live, waiting, and needs-attention work.':view==='history'?'Completed and failed task records.':'All matching task records.'}</p></div><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><div className="board-filters" aria-label="Task board filters"><div className="view-tabs" role="group" aria-label="Task view"><button className={view==='active'?'selected':''} onClick={()=>setView('active')}>Live work</button><button className={view==='history'?'selected':''} onClick={()=>setView('history')}>History {history>0&&<span>{history}</span>}</button><button className={view==='all'?'selected':''} onClick={()=>setView('all')}>All</button></div><label className="sr-only" htmlFor="task-search">Search tasks</label><input id="task-search" value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search tasks"/><label className="sr-only" htmlFor="project-filter">Filter by project</label><select id="project-filter" value={project} onChange={e=>setProject(e.target.value)}><option value="">All projects</option>{projects.map(p=><option value={p} key={p}>{p}</option>)}</select></div><Board tasks={filtered} overview={d} empty={empty}/>{createOpen&&<Create overview={d} close={()=>setCreateOpen(false)}/>}</main>}
|
||||
|
||||
@@ -40,7 +44,7 @@ function taskDetailExplanation(d:Detail){if(d.session?.pending_approval)return '
|
||||
|
||||
function Workers(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});if(q.isLoading)return <main className="page loading">Fetching worker heartbeats…</main>;if(q.error)return <main className="page error" role="alert">Worker pool unavailable: {String(q.error)}</main>;const d=q.data!,workers=d.workers;const herdr=(status?:string)=>status==='reachable'?'local herdr reachable':status==='unreachable'?'local herdr unreachable':'local herdr not yet checked';return <main className="page"><Link className="back" to="/">← Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Worker pool</p><h1>Available capacity.</h1></div><p>{workers.filter(w=>w.online).length} of {workers.length} registered workers are reachable right now.</p></div>{workers.length?<><p className="worker-note">Heartbeat proves the worker can reach Orchestra. Local herdr status is separately reported by that worker; it is never inferred from legacy coordinator TCP probes.</p><table><thead><tr><th>Worker</th><th>Heartbeat</th><th>Local herdr</th><th>Active work</th><th>Last error</th><th>Last heartbeat</th></tr></thead><tbody>{workers.map(w=><tr key={w.id}><td>{w.id}</td><td className={w.online?'online':'offline'}><span className="status-dot"/> {w.online?'reachable':'overdue'}</td><td className={w.health?.herdr_status==='reachable'?'online':w.health?.herdr_status==='unreachable'?'offline':''}>{herdr(w.health?.herdr_status)}<small>{date(w.health?.checked_at)}</small></td><td>{w.health?.active_task_id?<><span>{w.health.active_task_id}</span><small>{w.health.active_pane_id||'pane unknown'}</small></>:'idle'}</td><td>{w.health?.last_error?<><span>{w.health.last_error}</span><small>{date(w.health.error_at)}</small></>:'—'}</td><td>{date(w.last_seen)}</td></tr>)}</tbody></table></>:<section className="empty-panel"><i className="branch-mark"/><h2>No workers registered</h2><p>Connect a worker to begin leasing queued tasks.</p></section>}</main>}
|
||||
function Artifact(){const {ref=''}=useParams();const q=useQuery({queryKey:['artifact',ref],queryFn:()=>api.artifact(ref)});return <main className="page"><Link className="back" to="/">← Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Evidence artifact</p><h1>Recorded output.</h1></div><p className="machine">{ref}</p></div>{q.isLoading?<p className="loading">Retrieving artifact…</p>:q.error?<p className="error" role="alert">{String(q.error)}</p>:<pre>{q.data}</pre>}</main>}
|
||||
function Login({onAuthenticated,message}:{onAuthenticated:()=>void;message?:string}){const [token,setToken]=useState(''),[error,setError]=useState(''),[pending,setPending]=useState(false);const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');setPending(true);try{await api.login(token);setToken('');onAuthenticated()}catch(err){setError(String(err))}finally{setPending(false)}};return <main className="login-page"><section className="login-card"><p className="eyebrow">Orchestra control plane</p><h1>Sign in</h1><p>Enter the Web token to open a secure browser session.</p>{message&&<p className="session-message" role="status">{message}</p>}<form onSubmit={submit}><label>Web token<input type="password" autoComplete="current-password" autoFocus value={token} onChange={e=>setToken(e.target.value)} required/></label><button disabled={pending}>{pending?'Signing in…':'Sign in'}</button>{error&&<span className="error" role="alert">{error}</span>}</form></section></main>}
|
||||
function Login({onAuthenticated,message}:{onAuthenticated:()=>void;message?:string}){const input=useRef<HTMLInputElement>(null),[username,setUsername]=useState(''),[password,setPassword]=useState(''),[error,setError]=useState(''),[pending,setPending]=useState(false),[visible,setVisible]=useState(false),[capsLock,setCapsLock]=useState(false);useEffect(()=>input.current?.focus(),[]);const submit=async(e:React.FormEvent)=>{e.preventDefault();if(!username||!password){setError('Enter your username and password to continue.');(!username?input.current:document.getElementById('web-password'))?.focus();return}setError('');setPending(true);try{await api.login(username,password);setPassword('');onAuthenticated()}catch{setError('That username or password was not accepted. Check both and try again.')}finally{setPending(false)}};return <main className="login-page"><div className="login-orbit login-orbit-one"/><div className="login-orbit login-orbit-two"/><section className="login-card" aria-labelledby="login-title"><header className="login-brand"><i className="branch-mark" aria-hidden="true"/><div><span>ORCHESTRA</span><small>CONTROL PLANE</small></div></header><div className="login-heading"><p className="eyebrow">Operator access</p><h1 id="login-title">Welcome back.</h1><p>Sign in to open this browser’s operator session.</p></div>{message&&<p className="session-message" role="status">{message}</p>}<form onSubmit={submit} noValidate><label htmlFor="web-username">Username</label><input ref={input} id="web-username" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={e=>{setUsername(e.target.value);if(error)setError('')}} aria-invalid={!!error} placeholder="Operator username"/><label htmlFor="web-password">Password</label><div className="token-field"><input id="web-password" type={visible?'text':'password'} autoComplete="current-password" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={password} onChange={e=>{setPassword(e.target.value);if(error)setError('')}} onKeyUp={e=>setCapsLock(e.getModifierState('CapsLock'))} onKeyDown={e=>setCapsLock(e.getModifierState('CapsLock'))} aria-invalid={!!error} aria-describedby="login-help login-error" placeholder="Password"/><button type="button" className="reveal-token" onClick={()=>setVisible(v=>!v)} aria-label={visible?'Hide password':'Show password'} aria-pressed={visible}>{visible?'Hide':'Show'}</button></div><div className="token-status"><span id="login-help">Your password is verified against a bcrypt hash and never stored in this browser.</span>{capsLock&&<span className="caps-lock">Caps Lock is on</span>}</div>{error&&<p id="login-error" className="login-error" role="alert">{error}</p>}<button className="login-submit" disabled={pending||!username||!password}>{pending?<><span className="button-spinner"/>Opening session…</>:'Sign in'}</button></form><footer className="login-security"><span className="login-lock" aria-hidden="true">⌁</span><p>Successful sign-in creates an HttpOnly, same-site session cookie. Sessions expire after 12 hours.</p></footer></section></main>}
|
||||
function RoutesApp({onLogout}:{onLogout:()=>void}){return <Shell onLogout={onLogout}><Routes><Route path="/" element={<OverviewPage/>}/><Route path="/tasks/:taskID" element={<TaskDetail/>}/><Route path="/workers" element={<Workers/>}/><Route path="/artifacts/:ref" element={<Artifact/>}/></Routes></Shell>}
|
||||
function App(){const [ready,setReady]=useState(false),[checking,setChecking]=useState(true),[message,setMessage]=useState('');useEffect(()=>{const unauth=()=>{client.clear();setMessage('Your browser session expired. Sign in again to continue.');setReady(false);setChecking(false)};window.addEventListener('orchestra:unauthorized',unauth);api.overview().then(()=>setReady(true)).catch(()=>setReady(false)).finally(()=>setChecking(false));return()=>window.removeEventListener('orchestra:unauthorized',unauth)},[]);const logout=async()=>{await api.logout();client.clear();setMessage('You have signed out.');setReady(false)};if(checking)return <main className="login-page">Checking session…</main>;return ready?<RoutesApp onLogout={logout}/>:<Login message={message} onAuthenticated={()=>{client.clear();setMessage('');setReady(true)}}/>}
|
||||
function App(){const [ready,setReady]=useState(false),[checking,setChecking]=useState(true),[message,setMessage]=useState('');useEffect(()=>{const unauth=()=>{client.clear();setMessage('Your browser session expired. Sign in again to continue.');setReady(false);setChecking(false)};window.addEventListener('orchestra:unauthorized',unauth);api.overview().then(()=>setReady(true)).catch(()=>setReady(false)).finally(()=>setChecking(false));return()=>window.removeEventListener('orchestra:unauthorized',unauth)},[]);const logout=async()=>{await api.logout();client.clear();setMessage('You have signed out.');setReady(false)};if(checking)return <main className="login-page"><section className="session-check" aria-live="polite"><i className="branch-mark" aria-hidden="true"/><span className="button-spinner"/>Checking your session…</section></main>;return ready?<RoutesApp onLogout={logout}/>:<Login message={message} onAuthenticated={()=>{client.clear();setMessage('');setReady(true)}}/>}
|
||||
createRoot(document.getElementById('root')!).render(<React.StrictMode><QueryClientProvider client={client}><BrowserRouter><App/></BrowserRouter></QueryClientProvider></React.StrictMode>)
|
||||
|
||||
+2
-2
@@ -7,13 +7,13 @@
|
||||
@media(max-width:760px){.status-strip{grid-template-columns:1fr 1fr;margin-bottom:24px}.status-strip div:nth-child(2){border-right:0}.status-strip div:nth-child(-n+2){border-bottom:1px solid var(--line)}.palette-backdrop{padding-top:9vh}.palette{max-height:82vh}.palette-command{padding:13px 11px}}
|
||||
|
||||
.heading-actions{display:flex;align-items:end;gap:16px}.heading-actions button{white-space:nowrap}.modal-backdrop{position:fixed;inset:0;z-index:15;display:grid;place-items:center;padding:20px;background:rgba(8,7,5,.72)}.create.modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto}.modal-heading{display:flex;align-items:start;justify-content:space-between;gap:12px}.modal-heading h2{margin:0}.icon-button{display:grid;width:30px;height:30px;place-items:center;padding:0;border-color:var(--line-hi);color:var(--text-mid);background:transparent;font-size:21px;font-weight:400}.create form{display:grid;gap:9px}.create form label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}.create form input,.create form textarea{margin:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}.quiet-button{border-color:var(--line-hi);color:var(--text-mid);background:transparent}
|
||||
.login-page{display:grid;min-height:100vh;place-items:center;padding:20px;color:var(--text-hi)}.login-card{width:min(420px,100%);padding:28px;background:var(--bg-1);border:1px solid var(--line-hi);border-radius:var(--r-lg);box-shadow:var(--shadow-soft)}.login-card h1{margin:0 0 8px;font-size:31px;letter-spacing:-.04em}.login-card>p:not(.eyebrow){margin:0 0 22px;color:var(--text-mid);line-height:1.5}.login-card form{display:grid;gap:12px}.login-card label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}
|
||||
.login-page{position:relative;display:grid;min-height:100vh;place-items:center;overflow:hidden;padding:24px;color:var(--text-hi);background:radial-gradient(ellipse 70% 55% at 50% 0%,rgba(124,150,120,.16),transparent 70%),var(--bg-0)}.login-page:before{position:absolute;inset:0;background:linear-gradient(115deg,transparent 0 47%,rgba(244,234,220,.025) 47% 47.1%,transparent 47.1% 100%);content:'';pointer-events:none}.login-orbit{position:absolute;border:1px solid rgba(158,183,153,.12);border-radius:50%;pointer-events:none}.login-orbit-one{width:min(85vw,940px);height:min(85vw,940px);transform:translate(38%,-40%)}.login-orbit-two{width:min(62vw,650px);height:min(62vw,650px);transform:translate(-52%,53%)}.login-card{position:relative;width:min(460px,100%);padding:30px;background:linear-gradient(145deg,rgba(44,38,29,.96),rgba(27,23,18,.98));border:1px solid var(--line-hi);border-radius:20px;box-shadow:0 26px 80px rgba(0,0,0,.42),var(--shadow-soft)}.login-brand{display:flex;align-items:center;gap:10px;margin-bottom:35px;color:var(--accent-hi)}.login-brand .branch-mark{height:25px}.login-brand div{display:grid;gap:2px;font:600 14px var(--mono);letter-spacing:-.08em}.login-brand small{color:var(--text-lo);font:9px var(--sans);letter-spacing:.15em}.login-heading{margin-bottom:25px}.login-heading .eyebrow{margin-bottom:8px}.login-card h1{margin:0 0 8px;font-size:36px;line-height:1;letter-spacing:-.055em}.login-heading>p:not(.eyebrow){max-width:360px;margin:0;color:var(--text-mid);font-size:14px;line-height:1.55}.login-card form{display:grid;gap:10px}.login-card form>label{color:var(--text-mid);font-size:12px;font-weight:600}.token-field{position:relative}.token-field input{padding-right:68px;border-color:var(--line-hi);font-family:var(--mono);font-size:13px}.token-field input[aria-invalid="true"]{border-color:#d5a092;box-shadow:0 0 0 3px rgba(213,160,146,.1)}.reveal-token{position:absolute;top:50%;right:5px;transform:translateY(-50%);padding:6px 8px;border:0;color:var(--accent-hi);background:transparent;font-size:11px}.reveal-token:hover{background:var(--accent-dim)}.token-status{display:flex;justify-content:space-between;gap:10px;color:var(--text-lo);font-size:11px;line-height:1.4}.caps-lock{color:#d5a092}.login-error{margin:2px 0;padding:9px 10px;border:1px solid rgba(213,160,146,.5);border-radius:var(--r-sm);color:#e4b0a2;background:rgba(213,160,146,.09);font-size:12px;line-height:1.4}.login-submit{display:flex;min-height:42px;align-items:center;justify-content:center;gap:8px;margin-top:8px;font-size:13px}.button-spinner{display:inline-block;width:13px;height:13px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .75s linear infinite}.login-security{display:flex;gap:10px;margin-top:24px;padding-top:17px;border-top:1px solid var(--line);color:var(--text-lo)}.login-security p{margin:0;font-size:11px;line-height:1.5}.login-lock{color:var(--accent-hi);font:18px var(--mono)}.session-check{position:relative;display:flex;align-items:center;gap:11px;padding:16px 18px;border:1px solid var(--line-hi);border-radius:var(--r-md);background:var(--bg-1);color:var(--text-mid);font-size:13px;box-shadow:var(--shadow-soft)}.session-check .branch-mark{height:21px;color:var(--accent-hi)}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
@media(max-width:760px){.heading-actions{width:100%;align-items:stretch;flex-direction:column}.heading-actions button{align-self:flex-start}.modal-backdrop{padding:12px}.create.modal{max-height:calc(100vh - 24px)}}
|
||||
.blocked-groups{margin-top:32px}.blocked-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}.blocked-grid>section{min-height:120px;padding:12px;background:var(--bg-1);border:1px solid rgba(213,160,146,.34);border-radius:var(--r-md)}
|
||||
|
||||
/* Operator comfort pass: make live state scannable before the raw data. */
|
||||
.account{position:relative}.account-button{display:flex;align-items:center;gap:7px;padding:7px 9px;border-color:var(--line);color:var(--text-mid);background:transparent;font-size:11px;font-weight:500}.account-menu{position:absolute;top:calc(100% + 8px);right:0;z-index:10;display:grid;min-width:190px;gap:8px;padding:10px;border:1px solid var(--line-hi);border-radius:var(--r-sm);background:var(--bg-2);box-shadow:var(--shadow-soft);color:var(--text-lo);font-size:11px}.account-menu button{padding:7px 9px;text-align:left}.status-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent-hi);box-shadow:0 0 0 3px var(--accent-dim)}
|
||||
.section-title>div{display:grid;gap:3px}.section-title p,.section-copy,.worker-note{margin:0;color:var(--text-lo);font-size:12px;line-height:1.5}.card em{display:block;overflow:hidden;margin-top:9px;color:var(--text-lo);font-size:11px;font-style:normal;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.card:hover em{color:var(--text-mid)}.eligibility{display:grid;gap:3px;padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);background:var(--accent-dim);color:var(--text-mid);font-size:12px}.eligibility b{color:var(--accent-hi);font-size:12px}.eligibility.warning{border-color:rgba(213,160,146,.45);background:rgba(213,160,146,.09)}.eligibility.warning b{color:#d5a092}
|
||||
.section-title>div{display:grid;gap:3px}.section-title p,.section-copy,.worker-note{margin:0;color:var(--text-lo);font-size:12px;line-height:1.5}.card em{display:block;overflow:hidden;margin-top:9px;color:var(--text-lo);font-size:11px;font-style:normal;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.card:hover em{color:var(--text-mid)}.card-age{display:block;margin-top:8px;padding-top:8px;border-top:1px solid var(--line);color:#d5a092;font:10px var(--mono);line-height:1.3}.eligibility{display:grid;gap:3px;padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);background:var(--accent-dim);color:var(--text-mid);font-size:12px}.eligibility b{color:var(--accent-hi);font-size:12px}.eligibility.warning{border-color:rgba(213,160,146,.45);background:rgba(213,160,146,.09)}.eligibility.warning b{color:#d5a092}
|
||||
.board-filters{display:flex;align-items:center;gap:8px;margin:-2px 0 16px}.view-tabs{display:flex;gap:4px;margin-right:auto;padding:3px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--bg-1)}.view-tabs button{padding:6px 9px;border-color:transparent;color:var(--text-lo);background:transparent;font-size:11px;font-weight:500}.view-tabs button.selected{border-color:var(--accent-line);color:var(--accent-hi);background:var(--accent-dim)}.view-tabs span{margin-left:4px;color:var(--text-machine);font:10px var(--mono)}.board-filters input,.board-filters select{width:auto;min-width:150px;padding:8px 10px;border:1px solid var(--line-hi);border-radius:var(--r-sm);outline:none;color:var(--text-mid);background:var(--bg-1);font-size:12px}.board-filters select{cursor:pointer}.board-filters input:focus,.board-filters select:focus{border-color:var(--accent)}
|
||||
.task-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:-8px 0 20px;padding:13px 16px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.task-banner>div{display:flex;align-items:center;gap:12px}.task-banner .task-state{margin:0;color:var(--accent-hi);font-weight:600}.task-banner p{margin:0;color:var(--text-mid);font-size:13px;line-height:1.45}.task-banner>span{color:var(--text-lo);font:11px var(--mono);white-space:nowrap}.task-banner>span b{color:var(--text-machine);font-weight:500}.task-banner.state-blocked,.task-banner.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}
|
||||
.diagnosis{display:grid;gap:17px;margin:-8px 0 20px;padding:20px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.diagnosis.state-blocked,.diagnosis.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}.diagnosis-heading{display:flex;align-items:start;justify-content:space-between;gap:20px}.diagnosis .eyebrow{margin-bottom:6px}.diagnosis h2{margin:0 0 7px;font-size:22px;letter-spacing:-.03em}.diagnosis-heading p:not(.eyebrow){max-width:780px;margin:0;color:var(--text-mid);font-size:13px;line-height:1.5}.diagnosis .task-state{flex:none;margin:0;color:var(--accent-hi);font-weight:600}.diagnosis-evidence{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0}.diagnosis-evidence div{min-width:0;padding:10px 11px;border:1px solid var(--line);border-radius:var(--r-sm);background:rgba(20,17,13,.28)}.diagnosis-evidence dt{margin-bottom:5px;color:var(--text-lo);font-size:10px;letter-spacing:.07em;text-transform:uppercase}.diagnosis-evidence dd{overflow:hidden;margin:0;color:var(--text-machine);font-size:12px;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.next-action{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:13px 14px;border-left:3px solid var(--accent);background:rgba(20,17,13,.35)}.next-action>div{display:grid;gap:3px}.next-action span{color:var(--accent-hi);font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase}.next-action b{font-size:14px}.next-action p{margin:0;color:var(--text-mid);font-size:12px;line-height:1.45}.next-action small{flex:none;color:var(--text-lo);font:11px var(--mono)}
|
||||
|
||||
Reference in New Issue
Block a user