Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d08b246b5f
commit
724f30c02b
+18
-11
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
||||
import { Btn, Loading, Spinner, toast } from './ui'
|
||||
|
||||
type Filter = 'all' | 'running' | 'failed' | 'enabled'
|
||||
|
||||
@@ -37,22 +38,25 @@ export function Logs({ unit }: { unit: string }) {
|
||||
const [live, setLive] = useState(true)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [ready, setReady] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const pausedRef = useRef(false)
|
||||
pausedRef.current = paused
|
||||
const pending = useRef<LogEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setLines([]); setErr(''); pending.current = []
|
||||
setLines([]); setErr(''); setReady(false); pending.current = []
|
||||
let closed = false
|
||||
let readyTimer: number | undefined
|
||||
let sock: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const s = new WebSocket(wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`))
|
||||
sock = s
|
||||
s.onopen = () => { setLive(true); setErr('') }
|
||||
s.onopen = () => { setLive(true); setErr(''); readyTimer = window.setTimeout(() => setReady(true), 1500) }
|
||||
s.onmessage = (ev) => {
|
||||
setReady(true)
|
||||
const e = JSON.parse(ev.data) as LogEntry
|
||||
if (pausedRef.current) { pending.current.push(e); return }
|
||||
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), e] : [...prev, e]))
|
||||
@@ -61,7 +65,7 @@ export function Logs({ unit }: { unit: string }) {
|
||||
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||
return () => { closed = true; clearTimeout(retry); clearTimeout(readyTimer); sock?.close() }
|
||||
}, [unit])
|
||||
|
||||
// Resume: flush what arrived while paused.
|
||||
@@ -82,7 +86,7 @@ export function Logs({ unit }: { unit: string }) {
|
||||
<button className="btn" onClick={() => setLines([])}>Clear</button>
|
||||
</div>
|
||||
<div className="log-lines" ref={box}>
|
||||
{lines.length === 0 && <div className="muted">No journal entries.</div>}
|
||||
{lines.length === 0 && (ready ? <div className="muted">No journal entries.</div> : <div className="muted"><Spinner /> loading journal…</div>)}
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}>
|
||||
<span className="lt">{fmtTime(l.t)}</span>
|
||||
@@ -114,16 +118,16 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
|
||||
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
|
||||
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
|
||||
setBusy(action); setErr('')
|
||||
try { await api.serviceAction(name, action); await load(); onChanged() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button><div className="muted">{err || '…'}</div></div>
|
||||
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button>{err ? <div className="error">{err}</div> : <Loading label="Loading service…" />}</div>
|
||||
const p = d.props
|
||||
const running = d.active === 'active'
|
||||
const enabled = d.enabled === 'enabled'
|
||||
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : label}</button>
|
||||
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
|
||||
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
@@ -159,7 +163,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
|
||||
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
|
||||
<button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button>
|
||||
</div>
|
||||
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || '…'}</pre>}
|
||||
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || <span className="muted"><Spinner /> loading…</span>}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -171,9 +175,11 @@ export default function Services() {
|
||||
const [q, setQ] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [sel, setSel] = useState<string | null>(null)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setLoaded(true) }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [load])
|
||||
@@ -195,7 +201,7 @@ export default function Services() {
|
||||
<div className={'svc' + (sel ? ' has-sel' : '')}>
|
||||
<div className="svc-list">
|
||||
<div className="svc-tools">
|
||||
<input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="tools-row"><input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
||||
<div className="chips">
|
||||
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => (
|
||||
<button key={f} className={'chip' + (filter === f ? ' on' : '') + (f === 'failed' && counts.failed ? ' bad' : '')} onClick={() => setFilter(f)}>{label}</button>
|
||||
@@ -204,6 +210,7 @@ export default function Services() {
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-rows">
|
||||
{!loaded && <Loading label="Listing services…" />}
|
||||
{shown.map((u) => (
|
||||
<div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}>
|
||||
<span className={'sdot ' + stateClass(u)} />
|
||||
@@ -214,7 +221,7 @@ export default function Services() {
|
||||
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
{shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
{loaded && shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
</div>
|
||||
</div>
|
||||
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
|
||||
|
||||
Reference in New Issue
Block a user