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;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1,
|
||||||
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
font-size: 2.4rem;
|
font-size: 2.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -33,7 +41,9 @@ h1 {
|
|||||||
|
|
||||||
.meta-card,
|
.meta-card,
|
||||||
.banner,
|
.banner,
|
||||||
.table-card {
|
.table-card,
|
||||||
|
.detail-card,
|
||||||
|
.filters-card {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #1f2937;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -59,6 +69,45 @@ h1 {
|
|||||||
color: #fecaca;
|
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 {
|
.table-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -83,8 +132,13 @@ th {
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover td {
|
tbody tr {
|
||||||
background: rgba(148, 163, 184, 0.06);
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td,
|
||||||
|
.selected-row td {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-button {
|
.sort-button {
|
||||||
@@ -109,6 +163,11 @@ tr:hover td {
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preserve {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -127,6 +186,53 @@ tr:hover td {
|
|||||||
.status-completed { background: #064e3b; color: #a7f3d0; }
|
.status-completed { background: #064e3b; color: #a7f3d0; }
|
||||||
.status-cancelled { background: #374151; color: #d1d5db; }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -137,6 +243,10 @@ tr:hover td {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.table-card {
|
.table-card {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -144,4 +254,15 @@ tr:hover td {
|
|||||||
table {
|
table {
|
||||||
min-width: 760px;
|
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' },
|
{ key: 'created_at', label: 'Created At' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const defaultFilters = {
|
||||||
|
status: '',
|
||||||
|
type: '',
|
||||||
|
priority: '',
|
||||||
|
agent: '',
|
||||||
|
}
|
||||||
|
|
||||||
function shortId(id) {
|
function shortId(id) {
|
||||||
return id.slice(0, 8)
|
return id.slice(0, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
|
if (!value) return '—'
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +48,28 @@ function compareValues(a, b, key) {
|
|||||||
return String(left).localeCompare(String(right))
|
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() {
|
function App() {
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [sort, setSort] = useState({ key: 'created_at', direction: 'desc' })
|
const [sort, setSort] = useState({ key: 'created_at', direction: 'desc' })
|
||||||
|
const [filters, setFilters] = useState(defaultFilters)
|
||||||
|
const [selectedId, setSelectedId] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -57,6 +82,10 @@ function App() {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setItems(data)
|
setItems(data)
|
||||||
setError('')
|
setError('')
|
||||||
|
setSelectedId((current) => {
|
||||||
|
if (current && data.some((item) => item.id === current)) return current
|
||||||
|
return data[0]?.id ?? ''
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(err.message || 'Failed to load queue')
|
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 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()
|
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) {
|
function toggleSort(key) {
|
||||||
setSort((current) => ({
|
setSort((current) => ({
|
||||||
@@ -85,6 +141,10 @@ function App() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateFilter(key, value) {
|
||||||
|
setFilters((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
@@ -94,7 +154,8 @@ function App() {
|
|||||||
<p className="subtitle">Live queue view for all dispatched agent work items.</p>
|
<p className="subtitle">Live queue view for all dispatched agent work items.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-card">
|
<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>
|
<span>Polling every 10s</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -102,37 +163,146 @@ function App() {
|
|||||||
{error && <div className="banner error">{error}</div>}
|
{error && <div className="banner error">{error}</div>}
|
||||||
{loading && <div className="banner">Loading queue…</div>}
|
{loading && <div className="banner">Loading queue…</div>}
|
||||||
|
|
||||||
<section className="table-card">
|
<section className="filters-card">
|
||||||
<table>
|
<div className="filter-grid">
|
||||||
<thead>
|
<label>
|
||||||
<tr>
|
<span>Status</span>
|
||||||
{columns.map((column) => (
|
<select value={filters.status} onChange={(event) => updateFilter('status', event.target.value)}>
|
||||||
<th key={column.key}>
|
<option value="">All</option>
|
||||||
<button className="sort-button" onClick={() => toggleSort(column.key)}>
|
{filterOptions.statuses.map((value) => (
|
||||||
{column.label}
|
<option key={value} value={value}>
|
||||||
<span className="sort-indicator">
|
{value}
|
||||||
{sort.key === column.key ? (sort.direction === 'asc' ? '↑' : '↓') : '↕'}
|
</option>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</select>
|
||||||
</thead>
|
</label>
|
||||||
<tbody>
|
<label>
|
||||||
{sortedItems.map((item) => (
|
<span>Type</span>
|
||||||
<tr key={item.id}>
|
<select value={filters.type} onChange={(event) => updateFilter('type', event.target.value)}>
|
||||||
<td className="mono">{shortId(item.id)}</td>
|
<option value="">All</option>
|
||||||
<td>{item.type}</td>
|
{filterOptions.types.map((value) => (
|
||||||
<td>
|
<option key={value} value={value}>
|
||||||
<span className={`status-badge ${statusColors[item.status]}`}>{item.status}</span>
|
{value}
|
||||||
</td>
|
</option>
|
||||||
<td>{item.priority}</td>
|
))}
|
||||||
<td>{item.assigned_agent || '—'}</td>
|
</select>
|
||||||
<td>{formatDate(item.created_at)}</td>
|
</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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user