feat: add queue filters and detail view
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m3s

This commit is contained in:
Steve W
2026-04-12 01:14:07 +00:00
parent 39c299d926
commit 9356060fed
2 changed files with 327 additions and 36 deletions

View File

@@ -21,11 +21,19 @@
font-size: 0.8rem;
}
h1 {
h1,
h2 {
margin: 0;
}
h1 {
font-size: 2.4rem;
}
h2 {
font-size: 1.1rem;
}
.subtitle {
margin: 0.5rem 0 0;
color: #94a3b8;
@@ -33,7 +41,9 @@ h1 {
.meta-card,
.banner,
.table-card {
.table-card,
.detail-card,
.filters-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 16px;
@@ -59,6 +69,45 @@ h1 {
color: #fecaca;
}
.filters-card {
padding: 1rem;
margin-bottom: 1rem;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.45rem;
color: #cbd5e1;
}
label span {
font-size: 0.85rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 10px;
padding: 0.7rem 0.85rem;
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
gap: 1rem;
}
.table-card {
overflow: hidden;
}
@@ -83,8 +132,13 @@ th {
letter-spacing: 0.04em;
}
tr:hover td {
background: rgba(148, 163, 184, 0.06);
tbody tr {
cursor: pointer;
}
tr:hover td,
.selected-row td {
background: rgba(148, 163, 184, 0.08);
}
.sort-button {
@@ -109,6 +163,11 @@ tr:hover td {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.preserve {
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -127,6 +186,53 @@ tr:hover td {
.status-completed { background: #064e3b; color: #a7f3d0; }
.status-cancelled { background: #374151; color: #d1d5db; }
.detail-card {
padding: 1rem;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0;
}
.detail-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #1f2937;
}
.detail-row dt {
color: #94a3b8;
}
.detail-row dd {
margin: 0;
}
.empty-state,
.empty-detail {
color: #94a3b8;
padding: 1rem;
}
@media (max-width: 1100px) {
.content-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.app-shell {
padding: 1rem;
@@ -137,6 +243,10 @@ tr:hover td {
align-items: stretch;
}
.filter-grid {
grid-template-columns: 1fr 1fr;
}
.table-card {
overflow-x: auto;
}
@@ -144,4 +254,15 @@ tr:hover td {
table {
min-width: 760px;
}
.detail-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
}
@media (max-width: 560px) {
.filter-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -23,11 +23,19 @@ const columns = [
{ key: 'created_at', label: 'Created At' },
]
const defaultFilters = {
status: '',
type: '',
priority: '',
agent: '',
}
function shortId(id) {
return id.slice(0, 8)
}
function formatDate(value) {
if (!value) return '—'
return new Date(value).toLocaleString()
}
@@ -40,11 +48,28 @@ function compareValues(a, b, key) {
return String(left).localeCompare(String(right))
}
function buildOptions(items, key) {
return [...new Set(items.map((item) => item[key]).filter(Boolean))].sort((a, b) =>
String(a).localeCompare(String(b)),
)
}
function DetailRow({ label, value, mono = false, preserve = false }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd className={`${mono ? 'mono' : ''} ${preserve ? 'preserve' : ''}`}>{value || '—'}</dd>
</div>
)
}
function App() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [sort, setSort] = useState({ key: 'created_at', direction: 'desc' })
const [filters, setFilters] = useState(defaultFilters)
const [selectedId, setSelectedId] = useState('')
useEffect(() => {
let cancelled = false
@@ -57,6 +82,10 @@ function App() {
if (!cancelled) {
setItems(data)
setError('')
setSelectedId((current) => {
if (current && data.some((item) => item.id === current)) return current
return data[0]?.id ?? ''
})
}
} catch (err) {
if (!cancelled) setError(err.message || 'Failed to load queue')
@@ -73,10 +102,37 @@ function App() {
}
}, [])
const filterOptions = useMemo(
() => ({
statuses: buildOptions(items, 'status'),
types: buildOptions(items, 'type'),
priorities: buildOptions(items, 'priority'),
agents: buildOptions(items, 'assigned_agent'),
}),
[items],
)
const filteredItems = useMemo(
() =>
items.filter((item) => {
if (filters.status && item.status !== filters.status) return false
if (filters.type && item.type !== filters.type) return false
if (filters.priority && String(item.priority) !== String(filters.priority)) return false
if (filters.agent && item.assigned_agent !== filters.agent) return false
return true
}),
[items, filters],
)
const sortedItems = useMemo(() => {
const next = [...items].sort((a, b) => compareValues(a, b, sort.key))
const next = [...filteredItems].sort((a, b) => compareValues(a, b, sort.key))
return sort.direction === 'asc' ? next : next.reverse()
}, [items, sort])
}, [filteredItems, sort])
const selectedItem = useMemo(
() => sortedItems.find((item) => item.id === selectedId) || sortedItems[0] || null,
[sortedItems, selectedId],
)
function toggleSort(key) {
setSort((current) => ({
@@ -85,6 +141,10 @@ function App() {
}))
}
function updateFilter(key, value) {
setFilters((current) => ({ ...current, [key]: value }))
}
return (
<main className="app-shell">
<header className="page-header">
@@ -94,7 +154,8 @@ function App() {
<p className="subtitle">Live queue view for all dispatched agent work items.</p>
</div>
<div className="meta-card">
<span>{items.length} items</span>
<span>{filteredItems.length} visible items</span>
<span>{items.length} total items</span>
<span>Polling every 10s</span>
</div>
</header>
@@ -102,6 +163,59 @@ function App() {
{error && <div className="banner error">{error}</div>}
{loading && <div className="banner">Loading queue</div>}
<section className="filters-card">
<div className="filter-grid">
<label>
<span>Status</span>
<select value={filters.status} onChange={(event) => updateFilter('status', event.target.value)}>
<option value="">All</option>
{filterOptions.statuses.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<label>
<span>Type</span>
<select value={filters.type} onChange={(event) => updateFilter('type', event.target.value)}>
<option value="">All</option>
{filterOptions.types.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
<label>
<span>Priority</span>
<select
value={filters.priority}
onChange={(event) => updateFilter('priority', event.target.value)}
>
<option value="">All</option>
{filterOptions.priorities.map((value) => (
<option key={value} value={String(value)}>
{value}
</option>
))}
</select>
</label>
<label>
<span>Agent</span>
<select value={filters.agent} onChange={(event) => updateFilter('agent', event.target.value)}>
<option value="">All</option>
{filterOptions.agents.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
</div>
</section>
<section className="content-grid">
<section className="table-card">
<table>
<thead>
@@ -120,7 +234,11 @@ function App() {
</thead>
<tbody>
{sortedItems.map((item) => (
<tr key={item.id}>
<tr
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedItem?.id === item.id ? 'selected-row' : ''}
>
<td className="mono">{shortId(item.id)}</td>
<td>{item.type}</td>
<td>
@@ -131,9 +249,61 @@ function App() {
<td>{formatDate(item.created_at)}</td>
</tr>
))}
{!sortedItems.length && (
<tr>
<td colSpan={columns.length} className="empty-state">
No work items match the current filters.
</td>
</tr>
)}
</tbody>
</table>
</section>
<aside className="detail-card">
<div className="detail-header">
<h2>Work Item Details</h2>
{selectedItem && (
<span className={`status-badge ${statusColors[selectedItem.status]}`}>{selectedItem.status}</span>
)}
</div>
{selectedItem ? (
<dl className="detail-list">
<DetailRow label="ID" value={selectedItem.id} mono />
<DetailRow label="Project ID" value={selectedItem.project_id} mono />
<DetailRow label="Type" value={selectedItem.type} />
<DetailRow label="Description" value={selectedItem.description} preserve />
<DetailRow
label="Payload"
value={selectedItem.payload ? JSON.stringify(selectedItem.payload, null, 2) : '—'}
mono
preserve
/>
<DetailRow label="Priority" value={String(selectedItem.priority)} />
<DetailRow label="Assigned Agent" value={selectedItem.assigned_agent} />
<DetailRow label="Created By" value={selectedItem.created_by} />
<DetailRow label="Created At" value={formatDate(selectedItem.created_at)} />
<DetailRow label="Updated At" value={formatDate(selectedItem.updated_at)} />
<DetailRow label="Completed At" value={formatDate(selectedItem.completed_at)} />
<DetailRow label="Outcome" value={selectedItem.outcome} />
<DetailRow label="Notes" value={selectedItem.notes} preserve />
<DetailRow
label="Dispatch Log"
value={
selectedItem.dispatch_log?.length
? JSON.stringify(selectedItem.dispatch_log, null, 2)
: '[]'
}
mono
preserve
/>
</dl>
) : (
<div className="empty-detail">Select a work item to inspect its full details.</div>
)}
</aside>
</section>
</main>
)
}