1169 lines
33 KiB
Markdown
1169 lines
33 KiB
Markdown
# Web UI/UX Improvements — Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Holistic UI/UX improvement pass across all pages — better navigation, richer data display, improved micro-interactions, and new features that leverage existing API data.
|
|
|
|
**Architecture:** Pure frontend changes to app.js and style.css. No backend modifications needed — all improvements use existing query-api endpoints and WebSocket data. The app is vanilla JS (no framework, no build tools), so all changes are direct edits.
|
|
|
|
**Tech Stack:** Vanilla JS, CSS3 custom properties, uPlot charting, WebSocket real-time.
|
|
|
|
---
|
|
|
|
## Summary of Changes
|
|
|
|
| Area | Improvement | Impact |
|
|
|------|-------------|--------|
|
|
| Navigation | Command palette (Cmd+K) | High — fast access to any page/session/agent |
|
|
| Navigation | Keyboard shortcuts (g+d, g+s, g+a, g+i) | Medium — power user efficiency |
|
|
| Dashboard | Sparkline mini-charts on summary cards | High — at-a-glance trend visibility |
|
|
| Dashboard | Animated counter transitions | Medium — polished feel on live updates |
|
|
| Dashboard | Error pulse effect when errors spike | Medium — draws attention to problems |
|
|
| Sessions | Status filter pills (All/Active/Ended/Errored) | High — fast filtering without date pickers |
|
|
| Sessions | Duration column with relative bar | Medium — visual session comparison |
|
|
| Sessions | Active session highlight strip | Medium — immediately see what's running |
|
|
| Agents | Activity sparkline per agent lane | High — history at a glance per agent |
|
|
| Agents | Idle timer showing "last active X ago" | Medium — know when agents went quiet |
|
|
| Infrastructure | Uptime percentage badges | Medium — quick health assessment |
|
|
| Infrastructure | Last-check countdown timers | Low — shows data freshness |
|
|
| Cross-cutting | Page transition animations (crossfade) | Medium — polished navigation feel |
|
|
| Cross-cutting | Error notification badge in header nav | High — always-visible error awareness |
|
|
| Cross-cutting | Auto-refresh relative timestamps | Low — already partially done, needs consistency |
|
|
| Cross-cutting | Better skeleton loading screens | Low — content-aware shapes vs generic lines |
|
|
| Cross-cutting | Sticky section headers | Low — better scroll context |
|
|
|
|
---
|
|
|
|
## Task 1: Command Palette (Cmd+K)
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add ~120 lines near keyboard shortcut handler, lines 50-57)
|
|
- Modify: `cmd/web-ui/static/style.css` (add ~80 lines for palette styles)
|
|
|
|
A floating search/navigation overlay triggered by Cmd+K (or Ctrl+K). Shows a filterable list of quick actions:
|
|
- Navigate to pages (Dashboard, Sessions, Agents, Infrastructure)
|
|
- Search sessions/runs by ID (reuses existing `handleGlobalSearch`)
|
|
- Jump to specific agent by name
|
|
- Toggle theme
|
|
|
|
**Step 1: Add palette HTML and styles**
|
|
|
|
In `style.css`, add after the toast notification styles (~line 571):
|
|
|
|
```css
|
|
/* ── Command Palette ──────────────────────────────────────── */
|
|
.cmd-palette-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 200;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
justify-content: center;
|
|
padding-top: 20vh;
|
|
animation: fadeIn 150ms ease;
|
|
}
|
|
|
|
.cmd-palette {
|
|
width: min(520px, 90vw);
|
|
max-height: 400px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-xl);
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
animation: slideDown 150ms ease;
|
|
}
|
|
|
|
.cmd-palette-input-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.cmd-palette-input-wrap svg { color: var(--text-dim); flex-shrink: 0; }
|
|
|
|
.cmd-palette-input {
|
|
flex: 1;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-bright);
|
|
font-family: var(--font-body);
|
|
font-size: 0.95rem;
|
|
outline: none;
|
|
}
|
|
|
|
.cmd-palette-input::placeholder { color: var(--text-dim); }
|
|
|
|
.cmd-palette-results {
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
flex: 1;
|
|
}
|
|
|
|
.cmd-palette-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
transition: background 100ms;
|
|
}
|
|
|
|
.cmd-palette-item:hover,
|
|
.cmd-palette-item.selected {
|
|
background: var(--accent-dim);
|
|
}
|
|
|
|
.cmd-palette-item.selected {
|
|
outline: 1px solid var(--accent-glow);
|
|
}
|
|
|
|
.cmd-palette-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: var(--radius);
|
|
background: var(--surface-2);
|
|
color: var(--text-dim);
|
|
font-size: 0.8rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cmd-palette-item.selected .cmd-palette-icon,
|
|
.cmd-palette-item:hover .cmd-palette-icon {
|
|
color: var(--accent);
|
|
background: var(--accent-dim);
|
|
}
|
|
|
|
.cmd-palette-label {
|
|
flex: 1;
|
|
font-size: 0.85rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.cmd-palette-label strong {
|
|
color: var(--text-bright);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.cmd-palette-kbd {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
color: var(--text-dim);
|
|
background: var(--surface-2);
|
|
padding: 0.15rem 0.4rem;
|
|
border-radius: 3px;
|
|
border: 1px solid var(--border-soft);
|
|
}
|
|
|
|
.cmd-palette-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0.5rem 1rem;
|
|
border-top: 1px solid var(--border-soft);
|
|
font-size: 0.7rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.cmd-palette-footer kbd {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.65rem;
|
|
background: var(--surface-2);
|
|
padding: 0.1rem 0.3rem;
|
|
border-radius: 2px;
|
|
border: 1px solid var(--border-soft);
|
|
}
|
|
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
|
```
|
|
|
|
**Step 2: Add palette JS logic**
|
|
|
|
In `app.js`, add command palette logic after the `copyToClipboard`/`renderCopyButton` functions (~line 107). The palette should:
|
|
|
|
- Build a static list of page navigation items
|
|
- Build dynamic items from `agentsState.agents` keys
|
|
- Filter items on keystroke
|
|
- Navigate up/down with arrow keys, select with Enter, close with Escape
|
|
- Use `navigate()` for page transitions
|
|
|
|
```javascript
|
|
// ── Command Palette ─────────────────────────────────────
|
|
let paletteOpen = false;
|
|
let paletteSelectedIndex = 0;
|
|
|
|
function getCommandPaletteItems(query) {
|
|
const items = [
|
|
{ label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' },
|
|
{ label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' },
|
|
{ label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' },
|
|
{ label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' },
|
|
{ label: 'Toggle Theme', action: 'theme', icon: '◐' },
|
|
];
|
|
|
|
// Add agent items dynamically
|
|
if (agentsState && agentsState.agents) {
|
|
for (const [key, agent] of Object.entries(agentsState.agents)) {
|
|
items.push({
|
|
label: 'Agent: ' + (agent.name || key),
|
|
path: '/agents',
|
|
action: 'select-agent',
|
|
agentKey: key,
|
|
icon: isAgentOnline(agent) ? '●' : '○',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!query) return items;
|
|
const q = query.toLowerCase();
|
|
return items.filter(item => item.label.toLowerCase().includes(q));
|
|
}
|
|
|
|
function openCommandPalette() {
|
|
if (paletteOpen) return;
|
|
paletteOpen = true;
|
|
paletteSelectedIndex = 0;
|
|
|
|
const backdrop = document.createElement('div');
|
|
backdrop.className = 'cmd-palette-backdrop';
|
|
backdrop.id = 'cmd-palette-backdrop';
|
|
backdrop.innerHTML = `
|
|
<div class="cmd-palette">
|
|
<div class="cmd-palette-input-wrap">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/></svg>
|
|
<input class="cmd-palette-input" id="cmd-palette-input" type="text" placeholder="Type a command or search..." autofocus spellcheck="false" autocomplete="off">
|
|
</div>
|
|
<div class="cmd-palette-results" id="cmd-palette-results"></div>
|
|
<div class="cmd-palette-footer">
|
|
<span><kbd>↑↓</kbd> navigate</span>
|
|
<span><kbd>↵</kbd> select</span>
|
|
<span><kbd>esc</kbd> close</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(backdrop);
|
|
const input = document.getElementById('cmd-palette-input');
|
|
input.focus();
|
|
renderPaletteItems('');
|
|
|
|
input.addEventListener('input', () => {
|
|
paletteSelectedIndex = 0;
|
|
renderPaletteItems(input.value);
|
|
});
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
const items = document.querySelectorAll('.cmd-palette-item');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1);
|
|
updatePaletteSelection();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0);
|
|
updatePaletteSelection();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const selected = items[paletteSelectedIndex];
|
|
if (selected) selected.click();
|
|
} else if (e.key === 'Escape') {
|
|
closeCommandPalette();
|
|
}
|
|
});
|
|
|
|
backdrop.addEventListener('click', (e) => {
|
|
if (e.target === backdrop) closeCommandPalette();
|
|
});
|
|
}
|
|
|
|
function closeCommandPalette() {
|
|
paletteOpen = false;
|
|
const backdrop = document.getElementById('cmd-palette-backdrop');
|
|
if (backdrop) backdrop.remove();
|
|
}
|
|
|
|
function renderPaletteItems(query) {
|
|
const container = document.getElementById('cmd-palette-results');
|
|
if (!container) return;
|
|
const items = getCommandPaletteItems(query);
|
|
|
|
// If query looks like an ID (8+ hex chars), add a search option
|
|
if (query.length >= 8) {
|
|
items.unshift({ label: 'Search for ID: ' + query, action: 'search', query, icon: '🔍' });
|
|
}
|
|
|
|
container.innerHTML = items.map((item, i) => `
|
|
<div class="cmd-palette-item${i === paletteSelectedIndex ? ' selected' : ''}" data-index="${i}">
|
|
<div class="cmd-palette-icon">${item.icon}</div>
|
|
<span class="cmd-palette-label"><strong>${escapeHTML(item.label)}</strong></span>
|
|
${item.shortcut ? `<span class="cmd-palette-kbd">${item.shortcut}</span>` : ''}
|
|
</div>
|
|
`).join('');
|
|
|
|
container.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
|
el.addEventListener('click', () => executePaletteItem(items[i]));
|
|
el.addEventListener('mouseenter', () => {
|
|
paletteSelectedIndex = i;
|
|
updatePaletteSelection();
|
|
});
|
|
});
|
|
}
|
|
|
|
function updatePaletteSelection() {
|
|
document.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
|
el.classList.toggle('selected', i === paletteSelectedIndex);
|
|
if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
function executePaletteItem(item) {
|
|
closeCommandPalette();
|
|
if (item.action === 'theme') {
|
|
cycleTheme();
|
|
} else if (item.action === 'search') {
|
|
handleGlobalSearch(item.query);
|
|
} else if (item.action === 'select-agent') {
|
|
navigate('/agents');
|
|
setTimeout(() => selectAgent(item.agentKey, 'live'), 100);
|
|
} else if (item.path) {
|
|
navigate(item.path);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Wire Cmd+K and "g" key shortcuts into the global keydown handler**
|
|
|
|
Replace the existing keyboard shortcut block (lines 50-57) with:
|
|
|
|
```javascript
|
|
// Keyboard Shortcuts
|
|
let _pendingGoto = false;
|
|
document.addEventListener('keydown', (e) => {
|
|
// Ignore when typing in inputs
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
|
|
if (e.key === 'Escape') document.activeElement.blur();
|
|
return;
|
|
}
|
|
|
|
// Cmd+K or Ctrl+K — command palette
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
if (paletteOpen) closeCommandPalette();
|
|
else openCommandPalette();
|
|
return;
|
|
}
|
|
|
|
// '/' to focus search
|
|
if (e.key === '/' && !paletteOpen) {
|
|
e.preventDefault();
|
|
const searchInput = document.getElementById('global-search');
|
|
if (searchInput) searchInput.focus();
|
|
return;
|
|
}
|
|
|
|
// Escape closes palette
|
|
if (e.key === 'Escape' && paletteOpen) {
|
|
closeCommandPalette();
|
|
return;
|
|
}
|
|
|
|
// 'g' prefix for goto shortcuts
|
|
if (e.key === 'g' && !_pendingGoto) {
|
|
_pendingGoto = true;
|
|
setTimeout(() => { _pendingGoto = false; }, 800);
|
|
return;
|
|
}
|
|
|
|
if (_pendingGoto) {
|
|
_pendingGoto = false;
|
|
if (e.key === 'd') navigate('/');
|
|
else if (e.key === 's') navigate('/sessions');
|
|
else if (e.key === 'a') navigate('/agents');
|
|
else if (e.key === 'i') navigate('/infrastructure');
|
|
return;
|
|
}
|
|
});
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): add command palette (Cmd+K) and goto keyboard shortcuts"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Animated Counter Transitions on Dashboard
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add ~30 lines, modify `renderSummaryCards`)
|
|
- Modify: `cmd/web-ui/static/style.css` (add ~10 lines)
|
|
|
|
When dashboard counters update via WebSocket, animate the number change with a brief scale-up + color flash instead of jumping instantly.
|
|
|
|
**Step 1: Add counter animation CSS**
|
|
|
|
In `style.css`, add after the summary card styles:
|
|
|
|
```css
|
|
.summary-card-value.bumped {
|
|
animation: counterBump 400ms ease;
|
|
}
|
|
|
|
@keyframes counterBump {
|
|
0% { transform: scale(1); }
|
|
30% { transform: scale(1.15); color: var(--card-accent, var(--accent)); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
|
|
.metric-pill-value.bumped {
|
|
animation: counterBump 400ms ease;
|
|
}
|
|
```
|
|
|
|
**Step 2: Add animateCounter utility in app.js**
|
|
|
|
```javascript
|
|
function animateCounter(elementId, newValue) {
|
|
const el = document.getElementById(elementId);
|
|
if (!el) return;
|
|
const oldText = el.textContent;
|
|
const newText = String(newValue);
|
|
if (oldText === newText) return;
|
|
el.textContent = newText;
|
|
el.classList.remove('bumped');
|
|
void el.offsetWidth; // force reflow
|
|
el.classList.add('bumped');
|
|
}
|
|
```
|
|
|
|
**Step 3: Update renderSummaryCards to use animateCounter**
|
|
|
|
Replace the plain `el(id, val)` calls with `animateCounter` for the four main counters and the metrics strip values.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): animated counter transitions on dashboard updates"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Error Notification Badge in Header
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/index.html` (add badge element to nav)
|
|
- Modify: `cmd/web-ui/static/app.js` (track error count, update badge)
|
|
- Modify: `cmd/web-ui/static/style.css` (badge styles)
|
|
|
|
A small red dot/count badge appears on the Dashboard nav link when errors occur. Clears when user visits dashboard.
|
|
|
|
**Step 1: Add badge element to header nav in index.html**
|
|
|
|
Modify the Dashboard nav link to include a badge span:
|
|
|
|
```html
|
|
<a href="/">Dashboard<span class="nav-badge" id="nav-error-badge"></span></a>
|
|
```
|
|
|
|
**Step 2: Add badge CSS**
|
|
|
|
```css
|
|
nav a { position: relative; }
|
|
|
|
.nav-badge {
|
|
display: none;
|
|
position: absolute;
|
|
top: -2px;
|
|
right: -8px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
padding: 0 4px;
|
|
background: var(--error);
|
|
color: #fff;
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
line-height: 16px;
|
|
animation: badgePop 300ms ease;
|
|
}
|
|
|
|
.nav-badge.visible { display: block; }
|
|
|
|
@keyframes badgePop {
|
|
0% { transform: scale(0); }
|
|
60% { transform: scale(1.3); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
```
|
|
|
|
**Step 3: Add badge tracking logic in app.js**
|
|
|
|
```javascript
|
|
let _unseenErrors = 0;
|
|
|
|
function incrementErrorBadge() {
|
|
if (window.location.pathname === '/') return; // On dashboard, don't badge
|
|
_unseenErrors++;
|
|
const badge = document.getElementById('nav-error-badge');
|
|
if (badge) {
|
|
badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors);
|
|
badge.classList.add('visible');
|
|
}
|
|
}
|
|
|
|
function clearErrorBadge() {
|
|
_unseenErrors = 0;
|
|
const badge = document.getElementById('nav-error-badge');
|
|
if (badge) {
|
|
badge.classList.remove('visible');
|
|
badge.textContent = '';
|
|
}
|
|
}
|
|
```
|
|
|
|
Call `incrementErrorBadge()` in the global WS handler when an `error` event arrives, and call `clearErrorBadge()` at the top of `renderDashboard()`.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/index.html cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): error notification badge in header nav"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Session Status Filter Pills
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add filter UI and logic in `renderSessions`, ~40 lines)
|
|
- Modify: `cmd/web-ui/static/style.css` (pill toggle styles, ~30 lines)
|
|
|
|
Add quick-filter pills above the sessions table: **All** | **Active** | **Ended** | **With Errors**. These filter client-side against the already-loaded sessions list.
|
|
|
|
**Step 1: Add filter pill CSS**
|
|
|
|
```css
|
|
.filter-pills {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.filter-pill {
|
|
padding: 0.35rem 0.85rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-body);
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
transition: all 150ms;
|
|
}
|
|
|
|
.filter-pill:hover {
|
|
border-color: var(--accent-glow);
|
|
color: var(--text);
|
|
}
|
|
|
|
.filter-pill.active {
|
|
background: var(--accent-dim);
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.filter-pill .pill-count {
|
|
margin-left: 0.35rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
opacity: 0.7;
|
|
}
|
|
```
|
|
|
|
**Step 2: Add filter pills HTML in renderSessions**
|
|
|
|
Insert after the `.page-header` div and before `.filters`:
|
|
|
|
```javascript
|
|
const pillsHTML = `
|
|
<div class="filter-pills" id="session-pills">
|
|
<button class="filter-pill active" data-filter="all">All</button>
|
|
<button class="filter-pill" data-filter="active">Active</button>
|
|
<button class="filter-pill" data-filter="ended">Ended</button>
|
|
<button class="filter-pill" data-filter="errored">With Errors</button>
|
|
</div>
|
|
`;
|
|
```
|
|
|
|
**Step 3: Add pill click handlers and client-side filtering**
|
|
|
|
```javascript
|
|
let sessionFilterMode = 'all';
|
|
|
|
function applySessionFilter() {
|
|
const filtered = sessionsState.sessions.filter(s => {
|
|
if (sessionFilterMode === 'active') return isSessionActive(s);
|
|
if (sessionFilterMode === 'ended') return !isSessionActive(s);
|
|
if (sessionFilterMode === 'errored') return s.has_errors;
|
|
return true;
|
|
});
|
|
// Re-render table with filtered list
|
|
renderFilteredSessionsTable(filtered);
|
|
}
|
|
```
|
|
|
|
**Step 4: Wire pill buttons**
|
|
|
|
```javascript
|
|
document.querySelectorAll('#session-pills .filter-pill').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
sessionFilterMode = btn.dataset.filter;
|
|
applySessionFilter();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): session status filter pills (all/active/ended/errored)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Dashboard Sparklines on Summary Cards
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add sparkline renderer, ~50 lines)
|
|
- Modify: `cmd/web-ui/static/style.css` (sparkline styles, ~20 lines)
|
|
|
|
Add tiny inline SVG sparkline charts inside each of the four summary cards, showing the trend from the timeseries data. Uses the existing timeseries buckets — no new API calls.
|
|
|
|
**Step 1: Add sparkline CSS**
|
|
|
|
```css
|
|
.summary-card-sparkline {
|
|
position: absolute;
|
|
bottom: 0;
|
|
right: 0;
|
|
width: 60%;
|
|
height: 40px;
|
|
opacity: 0.3;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.summary-card { position: relative; overflow: hidden; }
|
|
```
|
|
|
|
**Step 2: Add sparkline SVG builder**
|
|
|
|
```javascript
|
|
function buildSparklineSVG(values, color) {
|
|
if (!values || values.length < 2) return '';
|
|
const max = Math.max(...values, 1);
|
|
const w = 100;
|
|
const h = 30;
|
|
const points = values.map((v, i) => {
|
|
const x = (i / (values.length - 1)) * w;
|
|
const y = h - (v / max) * h;
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
});
|
|
const polyline = points.join(' ');
|
|
// Area fill: close the path along the bottom
|
|
const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`;
|
|
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" class="summary-card-sparkline">
|
|
<path d="${areaPath}" fill="${color}" opacity="0.3"/>
|
|
<polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5"/>
|
|
</svg>`;
|
|
}
|
|
```
|
|
|
|
**Step 3: Inject sparklines into summary cards after timeseries loads**
|
|
|
|
After `renderSummaryCards()` is called with timeseries data, extract the relevant series and append SVG to each card:
|
|
|
|
```javascript
|
|
function renderDashSparklines() {
|
|
const ts = dashboardState.timeseries;
|
|
if (!ts || !ts.series || ts.series.length < 2) return;
|
|
const cards = document.querySelectorAll('.summary-card');
|
|
if (cards.length < 4) return;
|
|
|
|
const runsData = ts.series.map(b => b.runs || 0);
|
|
const toolsData = ts.series.map(b => b.tools || 0);
|
|
const errorsData = ts.series.map(b => b.errors || 0);
|
|
const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]);
|
|
|
|
// Remove existing sparklines
|
|
cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); });
|
|
|
|
cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)'));
|
|
cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)'));
|
|
cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)'));
|
|
cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)'));
|
|
}
|
|
```
|
|
|
|
Call `renderDashSparklines()` after `renderTimeseriesChart()` in both the initial load and `loadTimeseries()`.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): sparkline mini-charts on dashboard summary cards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Agent Lane Activity Sparklines
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add per-agent sparkline in lane render, ~30 lines)
|
|
- Modify: `cmd/web-ui/static/style.css` (~15 lines)
|
|
|
|
Add a small activity sparkline bar in each agent lane header showing event frequency over recent time (computed from the agent's `events` array).
|
|
|
|
**Step 1: Add lane sparkline CSS**
|
|
|
|
```css
|
|
.agent-lane-sparkline {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 1px;
|
|
height: 20px;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.agent-lane-sparkline-bar {
|
|
flex: 1;
|
|
background: var(--accent);
|
|
border-radius: 1px 1px 0 0;
|
|
opacity: 0.5;
|
|
min-height: 1px;
|
|
}
|
|
```
|
|
|
|
**Step 2: Build agent activity histogram**
|
|
|
|
```javascript
|
|
function buildAgentActivityBars(agent, bucketCount) {
|
|
const events = agent.events || [];
|
|
if (events.length === 0) return '';
|
|
const count = bucketCount || 20;
|
|
const now = Date.now();
|
|
const windowMS = 3600000; // 1 hour
|
|
const bucketMS = windowMS / count;
|
|
const buckets = new Array(count).fill(0);
|
|
|
|
for (const evt of events) {
|
|
const ts = new Date(getEnvelopeTS(evt)).getTime();
|
|
const age = now - ts;
|
|
if (age > windowMS || age < 0) continue;
|
|
const idx = Math.min(count - 1, Math.floor((windowMS - age) / bucketMS));
|
|
buckets[idx]++;
|
|
}
|
|
|
|
const max = Math.max(...buckets, 1);
|
|
return `<div class="agent-lane-sparkline">${buckets.map(b => {
|
|
const pct = (b / max * 100).toFixed(0);
|
|
return `<div class="agent-lane-sparkline-bar" style="height:${Math.max(pct, 3)}%"></div>`;
|
|
}).join('')}</div>`;
|
|
}
|
|
```
|
|
|
|
**Step 3: Insert into `renderAgentLanes`**
|
|
|
|
Add the sparkline HTML after the `agent-lane-meta` div in the lane header.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): activity sparkline bars in agent lane headers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Page Transition Animations
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (wrap `route()` with transition, ~15 lines)
|
|
- Modify: `cmd/web-ui/static/style.css` (transition styles, ~15 lines)
|
|
|
|
Add a subtle crossfade when navigating between pages. The `#app` element fades out briefly, content replaces, then fades in.
|
|
|
|
**Step 1: Add transition CSS**
|
|
|
|
```css
|
|
#app {
|
|
transition: opacity 120ms ease;
|
|
}
|
|
|
|
#app.transitioning {
|
|
opacity: 0;
|
|
transform: translateY(4px);
|
|
}
|
|
```
|
|
|
|
**Step 2: Wrap route() with transition**
|
|
|
|
```javascript
|
|
function route() {
|
|
cleanupLiveViews();
|
|
renderBreadcrumbs();
|
|
|
|
app.classList.add('transitioning');
|
|
requestAnimationFrame(() => {
|
|
// After one frame (opacity transition starts), swap content
|
|
setTimeout(() => {
|
|
const path = window.location.pathname;
|
|
// ... existing routing logic ...
|
|
updateActiveNav();
|
|
|
|
// Fade back in
|
|
requestAnimationFrame(() => {
|
|
app.classList.remove('transitioning');
|
|
});
|
|
}, 80); // Short delay for the fade-out
|
|
});
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): subtle crossfade page transitions"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Infrastructure Uptime Badges & Freshness Timers
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add uptime percentage calculation, freshness timer, ~30 lines)
|
|
- Modify: `cmd/web-ui/static/style.css` (~20 lines)
|
|
|
|
Add uptime percentage badges to service cards and "last checked X ago" timers that count up in real-time.
|
|
|
|
**Step 1: Add uptime badge CSS**
|
|
|
|
```css
|
|
.uptime-badge {
|
|
display: inline-block;
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 10px;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.uptime-badge.good { background: rgba(52, 211, 153, 0.15); color: var(--success); }
|
|
.uptime-badge.warn { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
|
|
.uptime-badge.bad { background: rgba(248, 113, 113, 0.15); color: var(--error); }
|
|
|
|
.freshness-timer {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
color: var(--text-dim);
|
|
}
|
|
```
|
|
|
|
**Step 2: Calculate uptime from uptime_sec and add to service card headers**
|
|
|
|
If `svc.uptime_sec` is available, compute a percentage relative to a 24h window and display as a small colored badge next to the service name:
|
|
|
|
```javascript
|
|
function getUptimeBadge(uptimeSec) {
|
|
if (!uptimeSec) return '';
|
|
const hours = uptimeSec / 3600;
|
|
const pct = Math.min(100, (hours / 24) * 100);
|
|
const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad';
|
|
return `<span class="uptime-badge ${cls}">${pct.toFixed(0)}% / 24h</span>`;
|
|
}
|
|
```
|
|
|
|
**Step 3: Add freshness timers to VM cards**
|
|
|
|
Add a `data-updated-at` attribute to the "Updated X ago" text, and refresh it with `setInterval` on the infrastructure page:
|
|
|
|
```javascript
|
|
let _infraTimerInterval = null;
|
|
|
|
// In renderInfrastructure, after rendering:
|
|
_infraTimerInterval = setInterval(() => {
|
|
document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => {
|
|
el.textContent = 'Updated ' + relativeTime(el.dataset.ts);
|
|
});
|
|
}, 10000);
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): uptime badges and freshness timers on infrastructure page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Better Skeleton Loading Screens
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add content-aware skeletons per page, ~40 lines)
|
|
- Modify: `cmd/web-ui/static/style.css` (~25 lines)
|
|
|
|
Replace generic skeleton rows with page-specific loading placeholders that match the shape of real content (card grids, timeline items, lane cards).
|
|
|
|
**Step 1: Add skeleton variant CSS**
|
|
|
|
```css
|
|
.skeleton-card {
|
|
background: var(--card);
|
|
border: 1px solid var(--border-soft);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem;
|
|
min-height: 120px;
|
|
}
|
|
|
|
.skeleton-card .skeleton-line { margin-bottom: 0.5rem; }
|
|
|
|
.skeleton-summary-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.skeleton-timeline-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem;
|
|
border-radius: var(--radius);
|
|
background: var(--card);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.skeleton-circle {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
background: var(--surface-2);
|
|
animation: skeletonPulse 1.5s ease-in-out infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
```
|
|
|
|
**Step 2: Add page-specific skeleton builders**
|
|
|
|
```javascript
|
|
function dashboardSkeleton() {
|
|
return `
|
|
<div class="skeleton-summary-row">${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:20%;height:2rem"></div></div>').join('')}</div>
|
|
<div class="skeleton-line" style="width:100%;height:200px;border-radius:var(--radius-lg)"></div>
|
|
`;
|
|
}
|
|
|
|
function sessionsSkeleton() {
|
|
return Array(6).fill(`
|
|
<div class="skeleton-timeline-item">
|
|
<div class="skeleton-circle"></div>
|
|
<div style="flex:1"><div class="skeleton-line" style="width:60%"></div><div class="skeleton-line" style="width:30%"></div></div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
```
|
|
|
|
**Step 3: Use these in the initial renders instead of `skeletonRows()`**
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): content-aware skeleton loading screens"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Session Duration Bar + Active Highlight
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (modify `renderSessionRow` and `refreshSessionsTable`)
|
|
- Modify: `cmd/web-ui/static/style.css` (~20 lines)
|
|
|
|
Add a small visual duration bar in the sessions table showing relative session length, and highlight active sessions with a subtle accent left-border.
|
|
|
|
**Step 1: Add session row enhancement CSS**
|
|
|
|
```css
|
|
tr.clickable.active {
|
|
border-left: 2px solid var(--accent);
|
|
background: var(--accent-dim);
|
|
}
|
|
|
|
.session-duration-bar {
|
|
display: inline-block;
|
|
height: 4px;
|
|
background: var(--accent);
|
|
border-radius: 2px;
|
|
min-width: 4px;
|
|
max-width: 80px;
|
|
opacity: 0.6;
|
|
margin-left: 0.5rem;
|
|
vertical-align: middle;
|
|
}
|
|
```
|
|
|
|
**Step 2: Compute max duration and add bar to each row**
|
|
|
|
In `refreshSessionsTable`, compute the longest session duration, then add a proportional bar element to each row's time column.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): session duration bars and active session highlighting"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Global Error-Aware WS Handler
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js` (add global WS listener for error badge, ~20 lines)
|
|
|
|
Wire a persistent global WS subscription that runs across all pages (not cleaned up on route change) solely for error counting to support the header badge from Task 3.
|
|
|
|
**Step 1: Add global WS subscription in DOMContentLoaded**
|
|
|
|
```javascript
|
|
// Global error tracking — persists across page navigations
|
|
subscribeWS(function globalErrorTracker(msg) {
|
|
if (msg.type !== 'message') return;
|
|
if (getEnvelopeType(msg.data) === 'error') {
|
|
incrementErrorBadge();
|
|
}
|
|
});
|
|
```
|
|
|
|
This goes in the `DOMContentLoaded` handler, after the search input setup.
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js
|
|
git commit -m "feat(web-ui): global WebSocket error tracking for header badge"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Polish Pass — Header Kbd Hint, Focus Rings, Hover States
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/index.html` (add Cmd+K hint to header)
|
|
- Modify: `cmd/web-ui/static/style.css` (~40 lines of polish)
|
|
|
|
Final polish: add a ⌘K hint button next to the search bar, improve focus ring visibility for keyboard navigation, and refine hover states on interactive elements.
|
|
|
|
**Step 1: Add Cmd+K button to header**
|
|
|
|
In `index.html`, add after the search `<kbd>/</kbd>`:
|
|
|
|
```html
|
|
<button class="cmd-k-hint" id="cmd-k-hint" title="Command palette" type="button">
|
|
<kbd>⌘K</kbd>
|
|
</button>
|
|
```
|
|
|
|
**Step 2: Add polish CSS**
|
|
|
|
```css
|
|
.cmd-k-hint {
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--border-soft);
|
|
border-radius: var(--radius);
|
|
padding: 0.2rem 0.5rem;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
transition: all 150ms;
|
|
}
|
|
|
|
.cmd-k-hint:hover {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
/* Better focus rings for keyboard navigation */
|
|
a:focus-visible,
|
|
button:focus-visible,
|
|
tr.clickable:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 2px;
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
/* Smooth hover lift on cards */
|
|
.vm-card:hover,
|
|
.service-card:hover,
|
|
.agent-lane:hover {
|
|
transform: translateY(-2px);
|
|
transition: transform 200ms ease, box-shadow 200ms ease;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
}
|
|
```
|
|
|
|
**Step 3: Wire the Cmd+K button**
|
|
|
|
```javascript
|
|
document.getElementById('cmd-k-hint')?.addEventListener('click', openCommandPalette);
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/index.html cmd/web-ui/static/app.js cmd/web-ui/static/style.css
|
|
git commit -m "feat(web-ui): polish pass — Cmd+K hint, focus rings, hover states"
|
|
```
|
|
|
|
---
|
|
|
|
## Execution Order
|
|
|
|
Tasks are designed to be independent and can be done in any order, but the recommended sequence groups related work:
|
|
|
|
1. **Task 1** — Command Palette (foundation for keyboard navigation)
|
|
2. **Task 3 + 11** — Error badge + global WS handler (small, linked)
|
|
3. **Task 2** — Animated counters (dashboard polish)
|
|
4. **Task 5** — Dashboard sparklines (dashboard data richness)
|
|
5. **Task 4** — Session filter pills (sessions UX)
|
|
6. **Task 10** — Session duration bars (sessions UX)
|
|
7. **Task 6** — Agent lane sparklines (agents data richness)
|
|
8. **Task 8** — Infrastructure uptime + freshness (infra UX)
|
|
9. **Task 7** — Page transitions (global polish)
|
|
10. **Task 9** — Better skeletons (global polish)
|
|
11. **Task 12** — Final polish pass
|
|
|
|
Total: ~12 tasks, ~500 lines of JS, ~300 lines of CSS. All pure frontend, no backend changes.
|