feat: improve web UI UX with global search, breadcrumbs, and better feedback
This commit is contained in:
+116
-9
@@ -35,7 +35,75 @@
|
||||
updateToggleBtn(getTheme());
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', cycleTheme);
|
||||
|
||||
// Global Search
|
||||
const searchInput = document.getElementById('global-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = searchInput.value.trim();
|
||||
if (val) handleGlobalSearch(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// '/' to focus search
|
||||
if (e.key === '/' && document.activeElement !== searchInput && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function handleGlobalSearch(id) {
|
||||
if (id.length < 8) {
|
||||
showToast('Search ID must be at least 8 characters', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try fetching as session first
|
||||
const sessionData = await api('/v1/sessions/' + id).catch(() => null);
|
||||
if (sessionData && sessionData.session) {
|
||||
navigate('/sessions/' + id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then try as run
|
||||
const runData = await api('/v1/runs/' + id).catch(() => null);
|
||||
if (runData && runData.run) {
|
||||
navigate('/runs/' + id);
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('ID not found', 'error');
|
||||
} catch (e) {
|
||||
showToast('Search failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text, el) {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('Copied to clipboard', 'success');
|
||||
if (el) {
|
||||
const originalText = el.textContent;
|
||||
el.textContent = 'Copied!';
|
||||
setTimeout(() => { el.textContent = originalText; }, 1500);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function renderCopyButton(text) {
|
||||
return `<button class="copy-btn" title="Copy to clipboard" onclick="event.stopPropagation(); copyToClipboard('${escapeHTML(text)}', this)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.5"/><path d="M10.5 1.5H2.5A1.5 1.5 0 0 0 1 3v8"/></svg>
|
||||
</button>`;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
const app = document.getElementById('app');
|
||||
@@ -184,6 +252,7 @@
|
||||
|
||||
function route() {
|
||||
cleanupLiveViews();
|
||||
renderBreadcrumbs();
|
||||
|
||||
const path = window.location.pathname;
|
||||
if (path === '/') {
|
||||
@@ -204,6 +273,44 @@
|
||||
updateActiveNav();
|
||||
}
|
||||
|
||||
function renderBreadcrumbs() {
|
||||
const el = document.getElementById('breadcrumbs');
|
||||
if (!el) return;
|
||||
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = [{ label: 'Dashboard', path: '/' }];
|
||||
let currentPath = '';
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
currentPath += '/' + part;
|
||||
let label = part.charAt(0).toUpperCase() + part.slice(1);
|
||||
|
||||
// Special labels for IDs
|
||||
if (part.length > 20 || /^[a-f0-9-]{32,}$/.test(part)) {
|
||||
label = part.substring(0, 8) + '…';
|
||||
}
|
||||
|
||||
if (i === parts.length - 1) {
|
||||
items.push({ label, current: true });
|
||||
} else {
|
||||
items.push({ label, path: currentPath });
|
||||
}
|
||||
});
|
||||
|
||||
el.innerHTML = items.map(item => {
|
||||
if (item.current) {
|
||||
return `<span class="current">${escapeHTML(item.label)}</span>`;
|
||||
}
|
||||
return `<a href="${item.path}" onclick="event.preventDefault(); navigate('${item.path}')">${escapeHTML(item.label)}</a><span class="sep">/</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function navigate(path) {
|
||||
history.pushState(null, '', path);
|
||||
route();
|
||||
@@ -293,8 +400,8 @@
|
||||
}
|
||||
|
||||
function statusIcon(status) {
|
||||
if (status === 'success') return '<span class="status-badge status-success"><span class="status-dot"></span>success</span>';
|
||||
if (status === 'error') return '<span class="status-badge status-error"><span class="status-dot"></span>error</span>';
|
||||
if (status === 'success') return '<span class="status-badge status-success"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><polyline points="13.5 4.5 6.5 11.5 2.5 7.5"/></svg>success</span>';
|
||||
if (status === 'error') return '<span class="status-badge status-error"><svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px"><line x1="13.5" y1="2.5" x2="2.5" y2="13.5"/><line x1="2.5" y1="2.5" x2="13.5" y2="13.5"/></svg>error</span>';
|
||||
return '<span class="status-badge status-unknown"><span class="status-dot"></span>unknown</span>';
|
||||
}
|
||||
|
||||
@@ -445,7 +552,7 @@
|
||||
: (active ? 'Open session' : 'Session ended');
|
||||
return `
|
||||
<tr class="clickable ${active ? 'active' : ''}" data-session="${escapeHTML(s.session_id)}">
|
||||
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</td>
|
||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
@@ -546,7 +653,7 @@
|
||||
? 'Currently active session'
|
||||
: (active ? 'Open session' : 'Session ended');
|
||||
return `
|
||||
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</td>
|
||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||
<td>${escapeHTML(s.host || '-')}</td>
|
||||
<td>${s.run_count}</td>
|
||||
@@ -668,7 +775,7 @@
|
||||
app.innerHTML = `
|
||||
<a href="/sessions" class="back-link">← Back to Sessions</a>
|
||||
<div class="page-header">
|
||||
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(sessionID.substring(0, 16))}...</span></h2>
|
||||
<h2>Session <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(sessionID)}">${escapeHTML(sessionID.substring(0, 16))}...</span>${renderCopyButton(sessionID)}</h2>
|
||||
<div class="session-status-line">
|
||||
<span class="fw-dot ${escapeHTML((s.framework || 'unknown').replace(/[^a-z0-9-]/g, '-'))} ${active ? 'active' : 'ended'}"></span>
|
||||
<span class="session-status-text">${active ? 'Active' : 'Ended'}</span>
|
||||
@@ -806,7 +913,7 @@
|
||||
return spans.map((sp, i) => {
|
||||
const kindClass = sp.kind || 'unknown';
|
||||
return `
|
||||
<tr class="expandable run-span-row" data-index="${i}">
|
||||
<tr class="expandable run-span-row ${sp.status === 'error' ? 'tr-error' : ''}" data-index="${i}">
|
||||
<td>
|
||||
<span class="expand-icon"></span>
|
||||
<span class="span-kind-badge ${escapeHTML(kindClass)}">${escapeHTML(sp.kind || '?')}</span>
|
||||
@@ -871,7 +978,7 @@
|
||||
app.innerHTML = `
|
||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
||||
<div class="page-header">
|
||||
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)">${escapeHTML(runID.substring(0, 16))}…</span> ${statusIcon(r.status)}</h2>
|
||||
<h2>Run <span style="font-family:var(--font-mono);font-size:1.1rem;color:var(--accent)" title="${escapeHTML(runID)}">${escapeHTML(runID.substring(0, 16))}…</span>${renderCopyButton(runID)} ${statusIcon(r.status)}</h2>
|
||||
<div class="meta-tiles">
|
||||
<div class="meta-tile">
|
||||
<div class="meta-tile-label">Started</div>
|
||||
@@ -1061,8 +1168,8 @@
|
||||
` : '<div class="empty-state" style="padding:0.5rem 0">No spans yet</div>';
|
||||
|
||||
return `
|
||||
<tr class="clickable expandable-run" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
|
||||
<td class="id-cell"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
||||
<tr class="clickable expandable-run ${r.status === 'error' ? 'tr-error' : ''}" data-run="${escapeHTML(r.run_id)}" data-index="${i}">
|
||||
<td class="id-cell" title="${escapeHTML(r.run_id)}"><span class="expand-icon"></span>${escapeHTML(r.run_id.substring(0, 12))}...${renderCopyButton(r.run_id)}</td>
|
||||
<td>${statusIcon(r.status)}</td>
|
||||
<td><span class="model-badge">${modelLabel}</span></td>
|
||||
<td>${r.tool_count || 0}</td>
|
||||
|
||||
@@ -18,10 +18,15 @@
|
||||
</div>
|
||||
<nav><a href="/">Dashboard</a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/infrastructure">Infra</a></nav>
|
||||
<div class="header-right">
|
||||
<div class="header-search">
|
||||
<input type="text" id="global-search" placeholder="Search ID..." spellcheck="false" autocomplete="off">
|
||||
<kbd>/</kbd>
|
||||
</div>
|
||||
<span class="ws-dot" id="ws-dot" title="Disconnected"></span>
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="breadcrumbs" class="breadcrumbs"></div>
|
||||
<main id="app">
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
|
||||
@@ -150,6 +150,86 @@ header nav a.active::after {
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ── Header Search ────────────────────────────────────────── */
|
||||
.header-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.header-search input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 0.35rem 0.75rem;
|
||||
padding-right: 2.2rem;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78rem;
|
||||
width: 140px;
|
||||
transition: width 0.2s ease, border-color 0.15s, box-shadow 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header-search input:focus {
|
||||
width: 220px;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.header-search kbd {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.header-search input:focus + kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Breadcrumbs ─────────────────────────────────────────── */
|
||||
.breadcrumbs {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumbs a:hover {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.breadcrumbs .sep {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.breadcrumbs .current {
|
||||
color: var(--text-bright);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Main ──────────────────────────────────────────────────── */
|
||||
main {
|
||||
max-width: 1240px;
|
||||
@@ -348,6 +428,36 @@ tr.clickable:hover td:first-child {
|
||||
padding-left: calc(1.25rem - 2px);
|
||||
}
|
||||
|
||||
tr.tr-error td {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
tr.tr-error .id-cell {
|
||||
color: var(--error);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 0.8rem;
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
|
||||
animation: skeletonPulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* ── Status badges ─────────────────────────────────────────── */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
@@ -406,6 +516,60 @@ tr.clickable:hover td:first-child {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.25rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
tr:hover .copy-btn,
|
||||
.meta-tile:hover .copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── Toast notifications ───────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-bright);
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
transition: transform 0.2s cubic-bezier(0.17, 0.67, 0.83, 0.67), opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-success { border-left: 3px solid var(--success); }
|
||||
.toast-error { border-left: 3px solid var(--error); }
|
||||
.toast-info { border-left: 3px solid var(--accent); }
|
||||
|
||||
/* ── Load more ─────────────────────────────────────────────── */
|
||||
.load-more {
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user