feat: add queue filters and detail view
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m3s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m3s
This commit is contained in:
129
src/App.css
129
src/App.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
234
src/App.jsx
234
src/App.jsx
@@ -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,37 +163,146 @@ function App() {
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
{loading && <div className="banner">Loading queue…</div>}
|
||||
|
||||
<section className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key}>
|
||||
<button className="sort-button" onClick={() => toggleSort(column.key)}>
|
||||
{column.label}
|
||||
<span className="sort-indicator">
|
||||
{sort.key === column.key ? (sort.direction === 'asc' ? '↑' : '↓') : '↕'}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
<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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="mono">{shortId(item.id)}</td>
|
||||
<td>{item.type}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${statusColors[item.status]}`}>{item.status}</span>
|
||||
</td>
|
||||
<td>{item.priority}</td>
|
||||
<td>{item.assigned_agent || '—'}</td>
|
||||
<td>{formatDate(item.created_at)}</td>
|
||||
</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>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key}>
|
||||
<button className="sort-button" onClick={() => toggleSort(column.key)}>
|
||||
{column.label}
|
||||
<span className="sort-indicator">
|
||||
{sort.key === column.key ? (sort.direction === 'asc' ? '↑' : '↓') : '↕'}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((item) => (
|
||||
<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>
|
||||
<span className={`status-badge ${statusColors[item.status]}`}>{item.status}</span>
|
||||
</td>
|
||||
<td>{item.priority}</td>
|
||||
<td>{item.assigned_agent || '—'}</td>
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user