e623d42eb8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1187 lines
45 KiB
HTML
1187 lines
45 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>OpenClaw Dashboard</title>
|
|
<style>
|
|
/* ── Variables ────────────────────────────────────────────────────────── */
|
|
:root {
|
|
--bg: #0d1117;
|
|
--surface: #161b22;
|
|
--surface2: #1c2128;
|
|
--border: #30363d;
|
|
--text: #e6edf3;
|
|
--muted: #8b949e;
|
|
--accent: #58a6ff;
|
|
--green: #3fb950;
|
|
--yellow: #d29922;
|
|
--orange: #e3b341;
|
|
--red: #f85149;
|
|
--purple: #bc8cff;
|
|
--mono: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
}
|
|
|
|
/* ── Reset & base ─────────────────────────────────────────────────────── */
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
a { color: var(--accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
button {
|
|
cursor: pointer;
|
|
background: var(--surface2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 5px 12px;
|
|
font-size: 13px;
|
|
transition: background .15s, border-color .15s;
|
|
}
|
|
button:hover { background: #222d3a; border-color: var(--accent); }
|
|
select, input {
|
|
background: var(--surface2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 5px 10px;
|
|
font-size: 13px;
|
|
outline: none;
|
|
}
|
|
select:focus, input:focus { border-color: var(--accent); }
|
|
pre, code, .mono { font-family: var(--mono); }
|
|
details > summary { cursor: pointer; user-select: none; }
|
|
details > summary::-webkit-details-marker { display: none; }
|
|
|
|
/* ── Layout ───────────────────────────────────────────────────────────── */
|
|
#app { display: flex; flex-direction: column; min-height: 100vh; }
|
|
|
|
.header {
|
|
position: sticky; top: 0; z-index: 100;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 0 20px;
|
|
}
|
|
.header-inner {
|
|
max-width: 1400px; margin: 0 auto;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
height: 52px;
|
|
}
|
|
.logo {
|
|
display: flex; align-items: center; gap: 10px;
|
|
font-size: 16px; font-weight: 600; color: var(--text);
|
|
text-decoration: none;
|
|
}
|
|
.logo-icon { font-size: 20px; }
|
|
.logo-sub { font-weight: 400; color: var(--muted); font-size: 13px; }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 12px; }
|
|
|
|
#main {
|
|
flex: 1;
|
|
max-width: 1400px; width: 100%;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
/* ── Badges ───────────────────────────────────────────────────────────── */
|
|
.badge {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 2px 8px; border-radius: 12px;
|
|
font-size: 11px; font-weight: 600; letter-spacing: .4px;
|
|
text-transform: uppercase;
|
|
}
|
|
.badge::before { content: '●'; font-size: 8px; }
|
|
.badge-active { background: #1a3a2a; color: var(--green); border: 1px solid #2d6040; }
|
|
.badge-idle { background: #1c2128; color: var(--muted); border: 1px solid var(--border); }
|
|
.badge-deleted { background: #3a1a1a; color: var(--red); border: 1px solid #6040408; }
|
|
.badge-reset { background: #3a2a0a; color: var(--orange); border: 1px solid #604020; }
|
|
|
|
/* ── Pill / tag ───────────────────────────────────────────────────────── */
|
|
.tag {
|
|
display: inline-block;
|
|
padding: 1px 7px; border-radius: 4px;
|
|
font-size: 11px; font-family: var(--mono);
|
|
background: var(--surface2); color: var(--muted);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
/* ── Refresh indicator ────────────────────────────────────────────────── */
|
|
#refresh-info { font-size: 12px; color: var(--muted); }
|
|
#refresh-info.refreshing { color: var(--accent); }
|
|
|
|
/* ── Filters bar ──────────────────────────────────────────────────────── */
|
|
.filters {
|
|
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.filters label { font-size: 12px; color: var(--muted); margin-right: 4px; }
|
|
#search { width: 200px; }
|
|
|
|
/* ── Table ────────────────────────────────────────────────────────────── */
|
|
.table-wrap { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
thead th {
|
|
background: var(--surface);
|
|
padding: 10px 14px;
|
|
text-align: left;
|
|
font-size: 11px; font-weight: 600; color: var(--muted);
|
|
text-transform: uppercase; letter-spacing: .5px;
|
|
border-bottom: 1px solid var(--border);
|
|
white-space: nowrap;
|
|
}
|
|
tbody tr {
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background .1s;
|
|
cursor: pointer;
|
|
}
|
|
tbody tr:last-child { border-bottom: none; }
|
|
tbody tr:hover { background: var(--surface2); }
|
|
td {
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
vertical-align: middle;
|
|
white-space: nowrap;
|
|
}
|
|
.td-agent { color: var(--accent); font-family: var(--mono); }
|
|
.td-id { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
|
.td-model { font-family: var(--mono); font-size: 12px; }
|
|
.td-num { text-align: right; font-family: var(--mono); }
|
|
.td-time { color: var(--muted); font-size: 12px; }
|
|
.td-dur { color: var(--muted); font-size: 12px; font-family: var(--mono); }
|
|
.td-cost { font-family: var(--mono); color: var(--yellow); }
|
|
.empty-row td { text-align: center; color: var(--muted); padding: 40px; }
|
|
|
|
/* ── Detail page ──────────────────────────────────────────────────────── */
|
|
.detail-header {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 16px 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.detail-title {
|
|
display: flex; align-items: center; gap: 12px;
|
|
margin-bottom: 12px; flex-wrap: wrap;
|
|
}
|
|
.detail-title h2 {
|
|
font-size: 15px; font-weight: 600;
|
|
font-family: var(--mono); color: var(--text);
|
|
}
|
|
.btn-back {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px; font-size: 12px;
|
|
}
|
|
.btn-copy {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 3px 8px; font-size: 11px;
|
|
}
|
|
.detail-meta {
|
|
display: flex; flex-wrap: wrap; gap: 20px;
|
|
font-size: 12px; color: var(--muted);
|
|
}
|
|
.detail-meta span { display: flex; align-items: center; gap: 5px; }
|
|
.detail-meta strong { color: var(--text); }
|
|
|
|
/* ── Live indicator ───────────────────────────────────────────────────── */
|
|
.live-dot {
|
|
display: inline-block; width: 8px; height: 8px;
|
|
border-radius: 50%; background: var(--green);
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
.live-dot.disconnected { background: var(--muted); animation: none; }
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: .5; transform: scale(.8); }
|
|
}
|
|
#live-status { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
|
|
|
|
/* ── Timeline ─────────────────────────────────────────────────────────── */
|
|
#timeline { display: flex; flex-direction: column; gap: 2px; }
|
|
|
|
.tl-msg {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
border-left: 3px solid var(--border);
|
|
overflow: hidden;
|
|
}
|
|
.tl-msg.user { border-left-color: var(--accent); }
|
|
.tl-msg.assistant { border-left-color: var(--green); }
|
|
|
|
.tl-msg-header {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 8px 14px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 11px;
|
|
}
|
|
.tl-role {
|
|
font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
|
|
font-size: 11px;
|
|
}
|
|
.role-user { color: var(--accent); }
|
|
.role-assistant { color: var(--green); }
|
|
.tl-ts { color: var(--muted); margin-left: auto; font-family: var(--mono); }
|
|
.tl-tokens { color: var(--muted); font-family: var(--mono); }
|
|
.tl-cost { color: var(--yellow); font-family: var(--mono); }
|
|
|
|
.tl-body { padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
|
|
|
|
/* Content types inside messages */
|
|
.c-text {
|
|
font-size: 13px; white-space: pre-wrap; word-break: break-word;
|
|
color: var(--text); line-height: 1.6;
|
|
}
|
|
.c-text-muted { color: var(--muted); }
|
|
|
|
.c-thinking {
|
|
border-left: 2px solid var(--purple);
|
|
padding-left: 10px;
|
|
}
|
|
.c-thinking summary {
|
|
font-size: 11px; color: var(--purple); padding: 4px 0;
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.c-thinking-text {
|
|
font-size: 12px; color: var(--muted);
|
|
white-space: pre-wrap; word-break: break-word;
|
|
margin-top: 6px; line-height: 1.5;
|
|
max-height: 300px; overflow-y: auto;
|
|
}
|
|
|
|
.c-tool-call {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px; overflow: hidden;
|
|
}
|
|
.c-tool-call summary {
|
|
padding: 7px 12px;
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 12px; font-family: var(--mono);
|
|
background: #1c2128;
|
|
}
|
|
.c-tool-call summary:hover { background: #222d3a; }
|
|
.tool-name { color: var(--orange); font-weight: 600; }
|
|
.tool-arrow { color: var(--muted); font-size: 10px; margin-right: auto; }
|
|
.tool-id-tag {
|
|
font-size: 10px; color: var(--muted);
|
|
padding: 1px 5px; border-radius: 3px;
|
|
background: var(--border);
|
|
}
|
|
.c-tool-args {
|
|
padding: 10px 12px;
|
|
font-size: 12px; font-family: var(--mono); color: #a9d1f7;
|
|
white-space: pre-wrap; word-break: break-word;
|
|
max-height: 400px; overflow-y: auto;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.c-image {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px; border-radius: 4px;
|
|
background: var(--surface2); border: 1px solid var(--border);
|
|
font-size: 12px; color: var(--muted);
|
|
}
|
|
|
|
/* System events (model change, thinking level, session start) */
|
|
.tl-sys {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 6px 14px;
|
|
font-size: 11px; color: var(--muted);
|
|
}
|
|
.tl-sys-line {
|
|
flex: 1; height: 1px; background: var(--border);
|
|
}
|
|
.tl-sys-text {
|
|
white-space: nowrap; padding: 3px 8px;
|
|
border-radius: 12px; background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
/* ── Loading / error states ───────────────────────────────────────────── */
|
|
.loading {
|
|
display: flex; align-items: center; justify-content: center;
|
|
gap: 10px; color: var(--muted); padding: 60px;
|
|
}
|
|
.spinner {
|
|
width: 18px; height: 18px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin .7s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.error-box {
|
|
background: #3a1a1a; border: 1px solid #6a2020;
|
|
border-radius: 8px; padding: 16px 20px;
|
|
color: var(--red); font-size: 13px;
|
|
}
|
|
|
|
/* ── Scroll-to-bottom btn ─────────────────────────────────────────────── */
|
|
#scroll-btn {
|
|
position: fixed; bottom: 24px; right: 24px;
|
|
background: var(--accent); color: #0d1117;
|
|
border: none; border-radius: 20px;
|
|
padding: 7px 14px; font-size: 12px; font-weight: 600;
|
|
display: none; z-index: 50;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.5);
|
|
}
|
|
#scroll-btn:hover { background: #79b8ff; }
|
|
|
|
/* ── Stats bar ────────────────────────────────────────────────────────── */
|
|
.stats-row {
|
|
display: flex; flex-wrap: wrap; gap: 10px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.stat-card {
|
|
background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 10px 16px;
|
|
display: flex; flex-direction: column; gap: 3px; min-width: 100px;
|
|
}
|
|
.stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
|
.stat-value { font-size: 18px; font-weight: 600; font-family: var(--mono); color: var(--text); }
|
|
.stat-value.green { color: var(--green); }
|
|
.stat-value.yellow { color: var(--yellow); }
|
|
.stat-value.accent { color: var(--accent); }
|
|
|
|
/* ── Responsive ───────────────────────────────────────────────────────── */
|
|
@media (max-width: 768px) {
|
|
#main { padding: 12px; }
|
|
.filters { gap: 8px; }
|
|
#search { width: 140px; }
|
|
td { padding: 8px 10px; }
|
|
.detail-meta { gap: 12px; }
|
|
.stats-row { gap: 8px; }
|
|
.stat-card { min-width: 80px; padding: 8px 12px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<header class="header">
|
|
<div class="header-inner">
|
|
<a class="logo" href="#/">
|
|
<span class="logo-icon">⚡</span>
|
|
<span>OpenClaw</span>
|
|
<span class="logo-sub">Dashboard</span>
|
|
</a>
|
|
<div class="header-right">
|
|
<span id="refresh-info"></span>
|
|
<span id="live-status" style="display:none">
|
|
<span class="live-dot disconnected" id="live-dot"></span>
|
|
<span id="live-label">Connecting…</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<main id="main">
|
|
<div class="loading"><div class="spinner"></div> Loading…</div>
|
|
</main>
|
|
</div>
|
|
<button id="scroll-btn" onclick="scrollToBottom()">↓ Latest</button>
|
|
|
|
<script>
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// State
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
const state = {
|
|
sessions: [],
|
|
filters: { agent: '', status: '', search: '' },
|
|
refreshTimer: null,
|
|
refreshAt: null,
|
|
ws: null,
|
|
autoScroll: true,
|
|
currentRoute: null,
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Utilities
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function esc(str) {
|
|
if (str === null || str === undefined) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function fmtNum(n) {
|
|
if (n === null || n === undefined || isNaN(n)) return '—';
|
|
return Number(n).toLocaleString();
|
|
}
|
|
|
|
function fmtTokens(n) {
|
|
if (!n) return '0';
|
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
if (n >= 10_000) return (n / 1_000).toFixed(1) + 'K';
|
|
return fmtNum(n);
|
|
}
|
|
|
|
function fmtCost(n) {
|
|
if (!n) return '$0.0000';
|
|
if (n < 0.0001) return '$<0.0001';
|
|
return '$' + Number(n).toFixed(4);
|
|
}
|
|
|
|
function fmtTimeAgo(ts) {
|
|
if (!ts) return '—';
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
if (diff < 0) return 'just now';
|
|
const s = Math.floor(diff / 1000);
|
|
if (s < 60) return s + 's ago';
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return m + 'm ago';
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return h + 'h ago';
|
|
return Math.floor(h / 24) + 'd ago';
|
|
}
|
|
|
|
function fmtDate(ts) {
|
|
if (!ts) return '—';
|
|
const d = new Date(ts);
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
+ ' ' + d.toTimeString().slice(0, 5);
|
|
}
|
|
|
|
function fmtDateFull(ts) {
|
|
if (!ts) return '—';
|
|
const d = new Date(ts);
|
|
return d.toLocaleString('en-US', {
|
|
month: 'short', day: 'numeric', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
});
|
|
}
|
|
|
|
function fmtDuration(start, end) {
|
|
if (!start) return '—';
|
|
const ms = new Date(end || Date.now()).getTime() - new Date(start).getTime();
|
|
if (ms < 0) return '—';
|
|
const s = Math.floor(ms / 1000);
|
|
if (s < 60) return s + 's';
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
const h = Math.floor(m / 60);
|
|
return h + 'h ' + (m % 60) + 'm';
|
|
}
|
|
|
|
function shortId(id) {
|
|
if (!id) return '—';
|
|
return id.length > 12 ? id.slice(0, 8) + '…' : id;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Router
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function navigate(hash) { window.location.hash = hash; }
|
|
|
|
function handleRoute() {
|
|
const hash = window.location.hash || '#/';
|
|
const parts = hash.slice(1).split('/').filter(Boolean);
|
|
|
|
// Cleanup previous state
|
|
stopAutoRefresh();
|
|
disconnectWS();
|
|
document.getElementById('live-status').style.display = 'none';
|
|
document.getElementById('refresh-info').textContent = '';
|
|
document.getElementById('scroll-btn').style.display = 'none';
|
|
|
|
state.currentRoute = hash;
|
|
|
|
if (parts[0] === 'session' && parts[1] && parts[2]) {
|
|
renderDetail(parts[1], parts[2]);
|
|
} else {
|
|
renderList();
|
|
}
|
|
}
|
|
|
|
window.addEventListener('hashchange', handleRoute);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Auto-refresh
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function stopAutoRefresh() {
|
|
if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; }
|
|
}
|
|
|
|
function startAutoRefresh(fn, interval = 10000) {
|
|
stopAutoRefresh();
|
|
state.refreshAt = Date.now() + interval;
|
|
state.refreshTimer = setInterval(() => {
|
|
state.refreshAt = Date.now() + interval;
|
|
fn();
|
|
}, interval);
|
|
updateRefreshInfo();
|
|
const tickTimer = setInterval(() => {
|
|
if (!state.refreshTimer) { clearInterval(tickTimer); return; }
|
|
updateRefreshInfo();
|
|
}, 1000);
|
|
}
|
|
|
|
function updateRefreshInfo() {
|
|
const el = document.getElementById('refresh-info');
|
|
if (!el || !state.refreshAt) return;
|
|
const sec = Math.max(0, Math.ceil((state.refreshAt - Date.now()) / 1000));
|
|
el.textContent = `↻ ${sec}s`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// List view
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
async function renderList() {
|
|
const main = document.getElementById('main');
|
|
// Show spinner only on first load
|
|
if (!state.sessions.length) {
|
|
main.innerHTML = `<div class="loading"><div class="spinner"></div> Loading sessions…</div>`;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/sessions');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
state.sessions = await res.json();
|
|
} catch (err) {
|
|
main.innerHTML = `<div class="error-box">Failed to load sessions: ${esc(err.message)}</div>`;
|
|
return;
|
|
}
|
|
|
|
renderListContent();
|
|
startAutoRefresh(renderList);
|
|
}
|
|
|
|
function renderListContent() {
|
|
// Build unique agent list
|
|
const agents = [...new Set(state.sessions.map(s => s.agent))].sort();
|
|
const statuses = ['active', 'idle', 'deleted', 'reset'];
|
|
|
|
const { agent: fa, status: fs, search: fq } = state.filters;
|
|
|
|
// Filter
|
|
const filtered = state.sessions.filter(s => {
|
|
if (fa && s.agent !== fa) return false;
|
|
if (fs && s.status !== fs) return false;
|
|
if (fq) {
|
|
const q = fq.toLowerCase();
|
|
if (!s.fileSessionId.toLowerCase().includes(q) &&
|
|
!s.agent.toLowerCase().includes(q) &&
|
|
!(s.cwd || '').toLowerCase().includes(q) &&
|
|
!(s.model || '').toLowerCase().includes(q)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `
|
|
<div class="filters">
|
|
<div>
|
|
<label>Agent</label>
|
|
<select id="f-agent">
|
|
<option value="">All agents</option>
|
|
${agents.map(a => `<option value="${esc(a)}" ${a === fa ? 'selected' : ''}>${esc(a)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Status</label>
|
|
<select id="f-status">
|
|
<option value="">All statuses</option>
|
|
${statuses.map(s => `<option value="${s}" ${s === fs ? 'selected' : ''}>${s}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<input id="search" type="search" placeholder="Search…" value="${esc(fq)}">
|
|
<span style="margin-left:auto;font-size:12px;color:var(--muted)">
|
|
${fmtNum(filtered.length)} / ${fmtNum(state.sessions.length)} sessions
|
|
</span>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Agent</th>
|
|
<th>Session ID</th>
|
|
<th>Status</th>
|
|
<th>Model</th>
|
|
<th class="td-num">Messages</th>
|
|
<th class="td-num">Tokens</th>
|
|
<th class="td-num">Cost</th>
|
|
<th>Last activity</th>
|
|
<th>Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="sessions-tbody">
|
|
${filtered.length ? filtered.map(s => sessionRow(s)).join('') : `
|
|
<tr class="empty-row"><td colspan="9">No sessions match the current filters</td></tr>
|
|
`}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
// Bind filters
|
|
document.getElementById('f-agent').addEventListener('change', e => {
|
|
state.filters.agent = e.target.value; renderListContent();
|
|
});
|
|
document.getElementById('f-status').addEventListener('change', e => {
|
|
state.filters.status = e.target.value; renderListContent();
|
|
});
|
|
document.getElementById('search').addEventListener('input', e => {
|
|
state.filters.search = e.target.value; renderListContent();
|
|
});
|
|
}
|
|
|
|
function sessionRow(s) {
|
|
const href = `#/session/${esc(s.agent)}/${esc(s.fileSessionId)}`;
|
|
return `
|
|
<tr onclick="navigate('${href}')">
|
|
<td class="td-agent">${esc(s.agent)}</td>
|
|
<td class="td-id mono">${esc(shortId(s.fileSessionId))}</td>
|
|
<td><span class="badge badge-${s.status}">${s.status}</span></td>
|
|
<td class="td-model">${esc(s.model || '—')}</td>
|
|
<td class="td-num">${fmtNum(s.messageCount)}</td>
|
|
<td class="td-num">${fmtTokens(s.totalTokens)}</td>
|
|
<td class="td-cost">${fmtCost(s.totalCost)}</td>
|
|
<td class="td-time">${fmtTimeAgo(s.lastTimestamp || s.firstTimestamp)}</td>
|
|
<td class="td-dur">${fmtDuration(s.firstTimestamp, s.lastTimestamp)}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Detail view
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
async function renderDetail(agent, id) {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `<div class="loading"><div class="spinner"></div> Loading session…</div>`;
|
|
|
|
let events;
|
|
try {
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
events = await res.json();
|
|
} catch (err) {
|
|
main.innerHTML = `
|
|
<div style="margin-bottom:12px">
|
|
<button class="btn-back" onclick="navigate('#/')">← Back</button>
|
|
</div>
|
|
<div class="error-box">Failed to load session: ${esc(err.message)}</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Compute summary from events
|
|
const summary = summariseEvents(events);
|
|
|
|
main.innerHTML = `
|
|
<div class="detail-header">
|
|
<div class="detail-title">
|
|
<button class="btn-back" onclick="navigate('#/')">← Back</button>
|
|
<span class="badge badge-${esc(summary.status)}">${esc(summary.status)}</span>
|
|
<h2 class="mono">${esc(agent)} / ${esc(id)}</h2>
|
|
<button class="btn-copy" onclick="copyId('${esc(id)}')">⧉ Copy ID</button>
|
|
</div>
|
|
<div class="detail-meta">
|
|
<span>🤖 <strong>${esc(summary.model || '—')}</strong></span>
|
|
<span>📁 <strong class="mono" title="${esc(summary.cwd)}">${esc(summary.cwd ? (summary.cwd.length > 40 ? '…' + summary.cwd.slice(-40) : summary.cwd) : '—')}</strong></span>
|
|
<span>🕒 Started <strong>${esc(fmtDateFull(summary.firstTimestamp))}</strong></span>
|
|
<span>⏱ <strong>${esc(fmtDuration(summary.firstTimestamp, summary.lastTimestamp))}</strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-row">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Messages</div>
|
|
<div class="stat-value accent">${fmtNum(summary.messageCount)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">User turns</div>
|
|
<div class="stat-value">${fmtNum(summary.userMessages)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Assistant turns</div>
|
|
<div class="stat-value">${fmtNum(summary.assistantMessages)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total tokens</div>
|
|
<div class="stat-value green">${fmtTokens(summary.totalTokens)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total cost</div>
|
|
<div class="stat-value yellow">${fmtCost(summary.totalCost)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="timeline"></div>
|
|
`;
|
|
|
|
// Render existing events
|
|
const tl = document.getElementById('timeline');
|
|
for (const ev of events) {
|
|
const el = renderEvent(ev);
|
|
if (el) tl.appendChild(el);
|
|
}
|
|
|
|
// Show live status bar
|
|
document.getElementById('live-status').style.display = 'flex';
|
|
|
|
// Connect WebSocket for live tail
|
|
connectWS(agent, id);
|
|
|
|
// Scroll to bottom
|
|
setupAutoScroll();
|
|
scrollToBottom();
|
|
}
|
|
|
|
function summariseEvents(events) {
|
|
let model = null, cwd = null, firstTimestamp = null, lastTimestamp = null;
|
|
let messageCount = 0, userMessages = 0, assistantMessages = 0;
|
|
let totalTokens = 0, totalCost = 0, status = 'idle';
|
|
|
|
for (const ev of events) {
|
|
if (ev.type === 'session') {
|
|
cwd = ev.cwd;
|
|
if (!firstTimestamp) firstTimestamp = ev.timestamp;
|
|
}
|
|
if (ev.type === 'custom' && ev.customType === 'model-snapshot' && ev.data?.modelId) {
|
|
model = ev.data.modelId;
|
|
}
|
|
if (ev.type === 'model_change' && ev.modelId && !model) {
|
|
model = ev.modelId;
|
|
}
|
|
if (ev.type === 'message') {
|
|
const msg = ev.message || {};
|
|
messageCount++;
|
|
if (!firstTimestamp && ev.timestamp) firstTimestamp = ev.timestamp;
|
|
if (ev.timestamp) lastTimestamp = ev.timestamp;
|
|
if (msg.usage) {
|
|
totalTokens += msg.usage.totalTokens || 0;
|
|
totalCost += msg.usage.cost?.total || 0;
|
|
}
|
|
if (!model && msg.model && msg.model !== 'delivery-mirror') model = msg.model;
|
|
if (msg.role === 'user') userMessages++;
|
|
if (msg.role === 'assistant') assistantMessages++;
|
|
}
|
|
}
|
|
|
|
// Determine status from current hash/filename indirectly — approximate from recency
|
|
const lastMs = lastTimestamp ? Date.now() - new Date(lastTimestamp).getTime() : Infinity;
|
|
status = lastMs < 5 * 60 * 1000 ? 'active' : 'idle';
|
|
|
|
return { model, cwd, firstTimestamp, lastTimestamp, messageCount, userMessages, assistantMessages, totalTokens, totalCost, status };
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Event rendering
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function renderEvent(ev) {
|
|
if (!ev || !ev.type) return null;
|
|
|
|
switch (ev.type) {
|
|
case 'session':
|
|
return sysEvent(`🚀 Session started · ${esc(fmtDateFull(ev.timestamp))}${ev.cwd ? ' · <span class="mono">' + esc(ev.cwd) + '</span>' : ''}`);
|
|
|
|
case 'model_change':
|
|
return sysEvent(`🤖 Model changed → <strong>${esc(ev.modelId || ev.provider)}</strong>`);
|
|
|
|
case 'thinking_level_change':
|
|
return sysEvent(`💭 Thinking level → <strong>${esc(ev.thinkingLevel)}</strong>`);
|
|
|
|
case 'custom':
|
|
if (ev.customType === 'model-snapshot') {
|
|
return sysEvent(`📸 Model snapshot · ${esc(ev.data?.provider)} / <strong>${esc(ev.data?.modelId)}</strong>`);
|
|
}
|
|
return null; // skip other custom events
|
|
|
|
case 'message':
|
|
return renderMessage(ev);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sysEvent(html) {
|
|
const div = document.createElement('div');
|
|
div.className = 'tl-sys';
|
|
div.innerHTML = `
|
|
<div class="tl-sys-line"></div>
|
|
<div class="tl-sys-text">${html}</div>
|
|
<div class="tl-sys-line"></div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
function renderMessage(ev) {
|
|
const msg = ev.message || {};
|
|
const role = msg.role || 'unknown';
|
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
const usage = msg.usage;
|
|
const ts = ev.timestamp;
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = `tl-msg ${role}`;
|
|
|
|
// Header row
|
|
const tokensHtml = usage?.totalTokens
|
|
? `<span class="tl-tokens">${fmtTokens(usage.totalTokens)} tok</span>`
|
|
: '';
|
|
const costHtml = usage?.cost?.total
|
|
? `<span class="tl-cost">${fmtCost(usage.cost.total)}</span>`
|
|
: '';
|
|
const tsHtml = ts ? `<span class="tl-ts">${esc(fmtDateFull(ts))}</span>` : '';
|
|
|
|
wrap.innerHTML = `
|
|
<div class="tl-msg-header">
|
|
<span class="tl-role role-${esc(role)}">${esc(role)}</span>
|
|
${tokensHtml}${costHtml}${tsHtml}
|
|
</div>
|
|
<div class="tl-body" id="body-${esc(ev.id || Math.random())}"></div>
|
|
`;
|
|
|
|
const body = wrap.querySelector('.tl-body');
|
|
|
|
for (const item of content) {
|
|
const el = renderContentItem(item);
|
|
if (el) body.appendChild(el);
|
|
}
|
|
|
|
// If body is empty (e.g. no renderable content), hide it
|
|
if (!body.children.length) {
|
|
body.remove();
|
|
wrap.style.borderBottom = 'none';
|
|
}
|
|
|
|
return wrap;
|
|
}
|
|
|
|
function renderContentItem(item) {
|
|
if (!item || !item.type) return null;
|
|
|
|
switch (item.type) {
|
|
case 'text': {
|
|
if (!item.text) return null;
|
|
const div = document.createElement('div');
|
|
// Dimmed for very short system-like messages
|
|
const isBrief = item.text.length < 80 && item.text.startsWith('✅');
|
|
div.className = 'c-text' + (isBrief ? ' c-text-muted' : '');
|
|
div.textContent = item.text;
|
|
return div;
|
|
}
|
|
|
|
case 'thinking': {
|
|
const details = document.createElement('details');
|
|
details.className = 'c-thinking';
|
|
const thinking = item.thinking || '';
|
|
details.innerHTML = `
|
|
<summary>💭 Thinking <small style="color:var(--muted)">(${fmtNum(thinking.length)} chars)</small></summary>
|
|
<div class="c-thinking-text">${esc(thinking)}</div>
|
|
`;
|
|
return details;
|
|
}
|
|
|
|
case 'toolCall': {
|
|
const details = document.createElement('details');
|
|
details.className = 'c-tool-call';
|
|
const argsStr = item.arguments
|
|
? JSON.stringify(item.arguments, null, 2)
|
|
: '{}';
|
|
details.innerHTML = `
|
|
<summary>
|
|
<span class="tool-name">⚙ ${esc(item.name)}</span>
|
|
<span class="tool-arrow">▸</span>
|
|
<span class="tool-id-tag">${esc((item.id || '').slice(-8))}</span>
|
|
</summary>
|
|
<div class="c-tool-args">${esc(argsStr)}</div>
|
|
`;
|
|
return details;
|
|
}
|
|
|
|
case 'image': {
|
|
const div = document.createElement('div');
|
|
div.className = 'c-image';
|
|
div.textContent = '📷 Image';
|
|
return div;
|
|
}
|
|
|
|
default: {
|
|
const div = document.createElement('div');
|
|
div.className = 'c-text c-text-muted';
|
|
div.textContent = `[${item.type}]`;
|
|
return div;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// WebSocket
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function disconnectWS() {
|
|
if (state.ws) {
|
|
state.ws.onclose = null;
|
|
state.ws.close();
|
|
state.ws = null;
|
|
}
|
|
}
|
|
|
|
function connectWS(agent, id) {
|
|
disconnectWS();
|
|
setLiveStatus('connecting');
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const url = `${proto}://${location.host}/ws/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`;
|
|
const ws = new WebSocket(url);
|
|
state.ws = ws;
|
|
|
|
ws.onopen = () => setLiveStatus('live');
|
|
|
|
ws.onmessage = e => {
|
|
try {
|
|
const ev = JSON.parse(e.data);
|
|
// Skip events we already rendered (all existing content was sent on open)
|
|
// The server sends ALL existing content first, then new lines.
|
|
// We only want to append truly new events (we already rendered on page load).
|
|
// Use a counter approach: track how many events we've received and skip first N.
|
|
ws._received = (ws._received || 0) + 1;
|
|
if (ws._received <= (ws._initialCount || 0)) return;
|
|
|
|
const tl = document.getElementById('timeline');
|
|
if (!tl) return;
|
|
const el = renderEvent(ev);
|
|
if (el) {
|
|
tl.appendChild(el);
|
|
if (state.autoScroll) scrollToBottom();
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setLiveStatus('disconnected');
|
|
// Attempt reconnect after 3s if still on same route
|
|
setTimeout(() => {
|
|
if (state.currentRoute === window.location.hash) connectWS(agent, id);
|
|
}, 3000);
|
|
};
|
|
|
|
ws.onerror = () => setLiveStatus('disconnected');
|
|
}
|
|
|
|
function setLiveStatus(state_) {
|
|
const dot = document.getElementById('live-dot');
|
|
const label = document.getElementById('live-label');
|
|
if (!dot || !label) return;
|
|
if (state_ === 'live') {
|
|
dot.className = 'live-dot';
|
|
label.textContent = 'Live';
|
|
} else if (state_ === 'connecting') {
|
|
dot.className = 'live-dot disconnected';
|
|
label.textContent = 'Connecting…';
|
|
} else {
|
|
dot.className = 'live-dot disconnected';
|
|
label.textContent = 'Disconnected';
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Auto-scroll
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function setupAutoScroll() {
|
|
state.autoScroll = true;
|
|
const btn = document.getElementById('scroll-btn');
|
|
|
|
window.addEventListener('scroll', () => {
|
|
const nearBottom = (window.innerHeight + window.scrollY) >= document.body.scrollHeight - 100;
|
|
state.autoScroll = nearBottom;
|
|
if (btn) btn.style.display = nearBottom ? 'none' : 'block';
|
|
}, { passive: true });
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Misc
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function copyId(id) {
|
|
navigator.clipboard.writeText(id).then(() => {
|
|
// Brief visual feedback
|
|
const btn = document.querySelector('.btn-copy');
|
|
if (btn) { btn.textContent = '✓ Copied'; setTimeout(() => btn.textContent = '⧉ Copy ID', 1200); }
|
|
});
|
|
}
|
|
|
|
// Handle the WS "initial dump vs new events" problem:
|
|
// We fetch all events via the REST API and render them, then open WS.
|
|
// The WS will replay all events from the start. We need to skip already-rendered ones.
|
|
// Simple approach: count events at detail load time and set ws._initialCount.
|
|
const _origRenderDetail = renderDetail;
|
|
// Patch renderDetail to track initial event count via ws._initialCount after WS connects.
|
|
// We do this by hooking connectWS: before connecting, count existing timeline children.
|
|
const _origConnectWS = connectWS;
|
|
|
|
// ── Override: after REST load, track initial timeline items count ──────────
|
|
// We intercept by observing wss.onopen to set ws._initialCount.
|
|
// The server sends all existing lines first; we skip that many messages.
|
|
(function patchConnectWS() {
|
|
const orig = window.connectWS;
|
|
// Count timeline items before WS opens — set after events are rendered.
|
|
const origFn = connectWS;
|
|
// This is handled in ws.onopen below via _initialCount being set to a large
|
|
// number, then reset once the "catch-up" is done.
|
|
// Actually: easier solution — track total lines in file via a counter.
|
|
// The server sends raw JSON lines. We count how many arrive before a 100ms gap.
|
|
})();
|
|
|
|
// Simpler approach for the WS duplicate problem:
|
|
// After connect, the server sends ALL existing lines then stops.
|
|
// We know how many events the REST API returned. Set that as skip count.
|
|
// Rewrite connectWS to accept initialCount.
|
|
function connectWSWithSkip(agent, id, skipCount) {
|
|
disconnectWS();
|
|
setLiveStatus('connecting');
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const url = `${proto}://${location.host}/ws/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`;
|
|
const ws = new WebSocket(url);
|
|
state.ws = ws;
|
|
ws._skip = skipCount;
|
|
ws._received = 0;
|
|
|
|
ws.onopen = () => setLiveStatus('live');
|
|
|
|
ws.onmessage = e => {
|
|
ws._received++;
|
|
if (ws._received <= ws._skip) return; // skip already-rendered events
|
|
|
|
try {
|
|
const ev = JSON.parse(e.data);
|
|
const tl = document.getElementById('timeline');
|
|
if (!tl) return;
|
|
const el = renderEvent(ev);
|
|
if (el) {
|
|
tl.appendChild(el);
|
|
if (state.autoScroll) scrollToBottom();
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setLiveStatus('disconnected');
|
|
setTimeout(() => {
|
|
if (state.currentRoute === window.location.hash) {
|
|
// On reconnect, skip nothing — we only want new events.
|
|
// But file might have grown. Count current timeline items? Too complex.
|
|
// Simpler: re-fetch events and rerender, then reconnect with new skip count.
|
|
const hash = window.location.hash;
|
|
const parts = hash.slice(1).split('/').filter(Boolean);
|
|
if (parts[0] === 'session' && parts[1] && parts[2]) {
|
|
renderDetail(parts[1], parts[2]);
|
|
}
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
ws.onerror = () => setLiveStatus('disconnected');
|
|
}
|
|
|
|
// Override renderDetail to use connectWSWithSkip
|
|
async function renderDetail(agent, id) {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `<div class="loading"><div class="spinner"></div> Loading session…</div>`;
|
|
|
|
let events;
|
|
try {
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(agent)}/${encodeURIComponent(id)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
events = await res.json();
|
|
} catch (err) {
|
|
main.innerHTML = `
|
|
<div style="margin-bottom:12px">
|
|
<button class="btn-back" onclick="navigate('#/')">← Back</button>
|
|
</div>
|
|
<div class="error-box">Failed to load session: ${esc(err.message)}</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const summary = summariseEvents(events);
|
|
|
|
main.innerHTML = `
|
|
<div class="detail-header">
|
|
<div class="detail-title">
|
|
<button class="btn-back" onclick="navigate('#/')">← Back</button>
|
|
<span class="badge badge-${esc(summary.status)}">${esc(summary.status)}</span>
|
|
<h2 class="mono">${esc(agent)} / ${esc(id)}</h2>
|
|
<button class="btn-copy" onclick="copyId('${esc(id)}')">⧉ Copy ID</button>
|
|
</div>
|
|
<div class="detail-meta">
|
|
<span>🤖 <strong>${esc(summary.model || '—')}</strong></span>
|
|
<span>📁 <span class="mono" style="font-size:11px" title="${esc(summary.cwd)}">${esc(summary.cwd ? (summary.cwd.length > 50 ? '…' + summary.cwd.slice(-50) : summary.cwd) : '—')}</span></span>
|
|
<span>🕒 <strong>${esc(fmtDateFull(summary.firstTimestamp))}</strong></span>
|
|
<span>⏱ <strong>${esc(fmtDuration(summary.firstTimestamp, summary.lastTimestamp))}</strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-row">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Messages</div>
|
|
<div class="stat-value accent">${fmtNum(summary.messageCount)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">User turns</div>
|
|
<div class="stat-value">${fmtNum(summary.userMessages)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Asst turns</div>
|
|
<div class="stat-value">${fmtNum(summary.assistantMessages)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Tokens</div>
|
|
<div class="stat-value green">${fmtTokens(summary.totalTokens)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Cost</div>
|
|
<div class="stat-value yellow">${fmtCost(summary.totalCost)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="timeline"></div>
|
|
`;
|
|
|
|
const tl = document.getElementById('timeline');
|
|
for (const ev of events) {
|
|
const el = renderEvent(ev);
|
|
if (el) tl.appendChild(el);
|
|
}
|
|
|
|
document.getElementById('live-status').style.display = 'flex';
|
|
|
|
// Count non-null events the server will replay
|
|
const skipCount = events.length;
|
|
connectWSWithSkip(agent, id, skipCount);
|
|
|
|
setupAutoScroll();
|
|
scrollToBottom();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Boot
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
handleRoute();
|
|
</script>
|
|
</body>
|
|
</html>
|