feat: add swarm strip to dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+484
-185
@@ -1,4 +1,43 @@
|
|||||||
(function() {
|
(function() {
|
||||||
|
|
||||||
|
// ── Theme toggle ─────────────────────────────────────────
|
||||||
|
const THEME_CYCLE = ['system', 'light', 'dark'];
|
||||||
|
const THEME_ICONS = {
|
||||||
|
system: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="2" width="14" height="10" rx="1.5"/><path d="M5 15h6M8 12v3"/></svg>',
|
||||||
|
light: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="3"/><line x1="8" y1="13" x2="8" y2="15"/><line x1="1" y1="8" x2="3" y2="8"/><line x1="13" y1="8" x2="15" y2="8"/><line x1="3.05" y1="3.05" x2="4.46" y2="4.46"/><line x1="11.54" y1="11.54" x2="12.95" y2="12.95"/><line x1="12.95" y1="3.05" x2="11.54" y2="4.46"/><line x1="4.46" y1="11.54" x2="3.05" y2="12.95"/></svg>',
|
||||||
|
dark: '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10a5 5 0 1 1-7-7 6 6 0 0 0 7 7z"/></svg>',
|
||||||
|
};
|
||||||
|
const THEME_LABELS = { system: 'System theme', light: 'Light theme', dark: 'Dark theme' };
|
||||||
|
|
||||||
|
function getTheme() { return localStorage.getItem('theme') || 'system'; }
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
if (theme === 'system') document.documentElement.removeAttribute('data-theme');
|
||||||
|
else document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateToggleBtn(theme) {
|
||||||
|
const btn = document.getElementById('theme-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.innerHTML = THEME_ICONS[theme];
|
||||||
|
btn.title = THEME_LABELS[theme];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(getTheme()) + 1) % THEME_CYCLE.length];
|
||||||
|
if (next === 'system') localStorage.removeItem('theme');
|
||||||
|
else localStorage.setItem('theme', next);
|
||||||
|
applyTheme(next);
|
||||||
|
updateToggleBtn(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateToggleBtn(getTheme());
|
||||||
|
const btn = document.getElementById('theme-toggle');
|
||||||
|
if (btn) btn.addEventListener('click', cycleTheme);
|
||||||
|
});
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -6,8 +45,10 @@
|
|||||||
const wsCallbacks = new Set();
|
const wsCallbacks = new Set();
|
||||||
|
|
||||||
let sessionsState = { sessions: [], cursor: null };
|
let sessionsState = { sessions: [], cursor: null };
|
||||||
|
let sessionsUnsubscribe = null;
|
||||||
let openclawState = { instances: {} };
|
let openclawState = { instances: {} };
|
||||||
let openclawUnsubscribe = null;
|
let openclawUnsubscribe = null;
|
||||||
|
let swarmState = { services: {} }; // keyed by service name
|
||||||
let agentsState = createAgentsState();
|
let agentsState = createAgentsState();
|
||||||
let agentsUnsubscribe = null;
|
let agentsUnsubscribe = null;
|
||||||
let dashboardState = null;
|
let dashboardState = null;
|
||||||
@@ -74,6 +115,14 @@
|
|||||||
agentsUnsubscribe();
|
agentsUnsubscribe();
|
||||||
agentsUnsubscribe = null;
|
agentsUnsubscribe = null;
|
||||||
}
|
}
|
||||||
|
if (sessionsState && sessionsState.timerInterval) {
|
||||||
|
clearInterval(sessionsState.timerInterval);
|
||||||
|
sessionsState.timerInterval = null;
|
||||||
|
}
|
||||||
|
if (sessionsUnsubscribe) {
|
||||||
|
sessionsUnsubscribe();
|
||||||
|
sessionsUnsubscribe = null;
|
||||||
|
}
|
||||||
if (dashboardUnsubscribe) {
|
if (dashboardUnsubscribe) {
|
||||||
dashboardUnsubscribe();
|
dashboardUnsubscribe();
|
||||||
dashboardUnsubscribe = null;
|
dashboardUnsubscribe = null;
|
||||||
@@ -86,6 +135,10 @@
|
|||||||
dashboardResizeObserver.disconnect();
|
dashboardResizeObserver.disconnect();
|
||||||
dashboardResizeObserver = null;
|
dashboardResizeObserver = null;
|
||||||
}
|
}
|
||||||
|
if (agentsState && agentsState.timerInterval) {
|
||||||
|
clearInterval(agentsState.timerInterval);
|
||||||
|
agentsState.timerInterval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function route() {
|
function route() {
|
||||||
@@ -136,6 +189,8 @@
|
|||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tryParseJSON(s) { try { return s ? JSON.parse(s) : null; } catch { return null; } }
|
||||||
|
|
||||||
function escapeHTML(value) {
|
function escapeHTML(value) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -277,8 +332,96 @@
|
|||||||
|
|
||||||
document.getElementById('load-more').addEventListener('click', loadSessions);
|
document.getElementById('load-more').addEventListener('click', loadSessions);
|
||||||
|
|
||||||
sessionsState = { sessions: [], cursor: null };
|
sessionsState = { sessions: [], cursor: null, timerInterval: null };
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
|
|
||||||
|
sessionsState.timerInterval = setInterval(updateSessionTimers, 30000);
|
||||||
|
sessionsUnsubscribe = subscribeWS(handleSessionsWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionActive(s) { return !s.ended_at; }
|
||||||
|
|
||||||
|
function renderSessionRow(s) {
|
||||||
|
const fw = s.framework || 'unknown';
|
||||||
|
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
||||||
|
const active = isSessionActive(s);
|
||||||
|
const dotState = active ? 'active' : 'ended';
|
||||||
|
const dotTitle = active ? 'Active session' : 'Session ended';
|
||||||
|
return `
|
||||||
|
<td class="id-cell">${escapeHTML(s.session_id.substring(0, 12))}…</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>
|
||||||
|
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSessionTimers() {
|
||||||
|
const tbody = document.getElementById('sessions-body');
|
||||||
|
if (!tbody) return;
|
||||||
|
sessionsState.sessions.forEach(s => {
|
||||||
|
const row = tbody.querySelector(`[data-session="${s.session_id}"]`);
|
||||||
|
if (row) {
|
||||||
|
const td = row.cells[4];
|
||||||
|
if (td) td.textContent = relativeTime(s.started_at);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSessionsWS(msg) {
|
||||||
|
if (msg.type !== 'message') return;
|
||||||
|
const eventType = getEnvelopeType(msg.data);
|
||||||
|
const correlation = getEnvelopeCorrelation(msg.data);
|
||||||
|
const sessionId = correlation?.session_id || msg.data.event?.id;
|
||||||
|
|
||||||
|
if (eventType === 'session.start') {
|
||||||
|
const source = msg.data.event?.source;
|
||||||
|
const newSession = {
|
||||||
|
session_id: sessionId,
|
||||||
|
started_at: msg.data.event?.ts,
|
||||||
|
framework: source?.framework || 'unknown',
|
||||||
|
host: source?.host || '-',
|
||||||
|
run_count: 1,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
sessionsState.sessions.unshift(newSession);
|
||||||
|
const tbody = document.getElementById('sessions-body');
|
||||||
|
if (tbody) {
|
||||||
|
const row = tbody.insertRow(0);
|
||||||
|
row.className = 'clickable active';
|
||||||
|
row.dataset.session = newSession.session_id;
|
||||||
|
row.innerHTML = renderSessionRow(newSession);
|
||||||
|
row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'run.start' && sessionId) {
|
||||||
|
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.run_count = (session.run_count || 0) + 1;
|
||||||
|
const tbody = document.getElementById('sessions-body');
|
||||||
|
if (tbody) {
|
||||||
|
const row = tbody.querySelector(`[data-session="${sessionId}"]`);
|
||||||
|
if (row) row.cells[3].textContent = session.run_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'session.end' && sessionId) {
|
||||||
|
const session = sessionsState.sessions.find(s => s.session_id === sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.ended_at = new Date().toISOString();
|
||||||
|
const tbody = document.getElementById('sessions-body');
|
||||||
|
if (tbody) {
|
||||||
|
const row = tbody.querySelector(`[data-session="${sessionId}"]`);
|
||||||
|
if (row) {
|
||||||
|
const dot = row.querySelector('.fw-dot');
|
||||||
|
dot.classList.remove('active');
|
||||||
|
dot.classList.add('ended');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
@@ -302,10 +445,13 @@
|
|||||||
tbody.innerHTML = sessionsState.sessions.map(s => {
|
tbody.innerHTML = sessionsState.sessions.map(s => {
|
||||||
const fw = s.framework || 'unknown';
|
const fw = s.framework || 'unknown';
|
||||||
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
||||||
|
const active = isSessionActive(s);
|
||||||
|
const dotState = active ? 'active' : 'ended';
|
||||||
|
const dotTitle = active ? 'Active session' : 'Session ended';
|
||||||
return `
|
return `
|
||||||
<tr class="clickable" data-session="${escapeHTML(s.session_id)}">
|
<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">${escapeHTML(s.session_id.substring(0, 12))}…</td>
|
||||||
<td><span class="fw-dot ${escapeHTML(fwClass)}"></span>${escapeHTML(fw)}</td>
|
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
||||||
<td>${escapeHTML(s.host || '-')}</td>
|
<td>${escapeHTML(s.host || '-')}</td>
|
||||||
<td>${s.run_count}</td>
|
<td>${s.run_count}</td>
|
||||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
||||||
@@ -358,6 +504,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Run ID</th>
|
<th>Run ID</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Tools</th>
|
||||||
<th>Spans</th>
|
<th>Spans</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
@@ -368,16 +516,19 @@
|
|||||||
const runDuration = r.ended_at
|
const runDuration = r.ended_at
|
||||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||||
: '-';
|
: '-';
|
||||||
|
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
||||||
return `
|
return `
|
||||||
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
||||||
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
||||||
<td>${statusIcon(r.status)}</td>
|
<td>${statusIcon(r.status)}</td>
|
||||||
|
<td><span class="model-badge">${modelLabel}</span></td>
|
||||||
|
<td>${r.tool_count || 0}</td>
|
||||||
<td>${r.span_count}</td>
|
<td>${r.span_count}</td>
|
||||||
<td>${escapeHTML(runDuration)}</td>
|
<td>${escapeHTML(runDuration)}</td>
|
||||||
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('') || '<tr><td colspan="5" class="empty-state">No runs</td></tr>'}
|
}).join('') || '<tr><td colspan="7" class="empty-state">No runs</td></tr>'}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,6 +542,50 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate('/sessions');
|
navigate('/sessions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sessionsUnsubscribe = subscribeWS((msg) => handleSessionWS(sessionID, msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSessionWS(sessionID, msg) {
|
||||||
|
if (msg.type !== 'message') return;
|
||||||
|
const correlation = getEnvelopeCorrelation(msg.data);
|
||||||
|
if (correlation?.session_id !== sessionID) return;
|
||||||
|
|
||||||
|
loadSessionData(sessionID);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionData(sessionID) {
|
||||||
|
if (!isCurrentPath('/sessions/' + sessionID)) return;
|
||||||
|
const data = await api('/v1/sessions/' + sessionID);
|
||||||
|
const runs = data.runs || [];
|
||||||
|
|
||||||
|
const tbody = document.querySelector('#app table tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
tbody.innerHTML = runs.map(r => {
|
||||||
|
const runDuration = r.ended_at
|
||||||
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||||
|
: '-';
|
||||||
|
const modelLabel = r.model ? escapeHTML(r.model.replace(/^claude-/, '')) : '-';
|
||||||
|
return `
|
||||||
|
<tr class="clickable" data-run="${escapeHTML(r.run_id)}">
|
||||||
|
<td class="id-cell">${escapeHTML(r.run_id.substring(0, 12))}...</td>
|
||||||
|
<td>${statusIcon(r.status)}</td>
|
||||||
|
<td><span class="model-badge">${modelLabel}</span></td>
|
||||||
|
<td>${r.tool_count || 0}</td>
|
||||||
|
<td>${r.span_count}</td>
|
||||||
|
<td>${escapeHTML(runDuration)}</td>
|
||||||
|
<td>${escapeHTML(new Date(r.started_at).toLocaleTimeString())}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('') || '<tr><td colspan="7" class="empty-state">No runs</td></tr>';
|
||||||
|
|
||||||
|
tbody.querySelectorAll('tr.clickable').forEach(row => {
|
||||||
|
row.addEventListener('click', () => navigate('/runs/' + row.dataset.run));
|
||||||
|
});
|
||||||
|
|
||||||
|
const countSpan = document.querySelector('.section-title .count');
|
||||||
|
if (countSpan) countSpan.textContent = runs.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderRun(runID) {
|
async function renderRun(runID) {
|
||||||
@@ -521,6 +716,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSwarmSnapshot(evt) {
|
||||||
|
const payload = getEnvelopePayload(evt);
|
||||||
|
const services = payload.services || [];
|
||||||
|
for (const svc of services) {
|
||||||
|
if (svc.name) swarmState.services[svc.name] = svc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSwarmServiceSnapshot(evt) {
|
||||||
|
const payload = getEnvelopePayload(evt);
|
||||||
|
const svc = payload.service;
|
||||||
|
if (svc && svc.name) swarmState.services[svc.name] = svc;
|
||||||
|
}
|
||||||
|
|
||||||
function renderOpenClawGrid() {
|
function renderOpenClawGrid() {
|
||||||
const names = Object.keys(openclawState.instances).sort();
|
const names = Object.keys(openclawState.instances).sort();
|
||||||
|
|
||||||
@@ -591,15 +800,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAgentsState() {
|
function createAgentsState() {
|
||||||
|
function agentBucket() {
|
||||||
|
return { sessions: {}, operations: {}, events: [], eventIDs: new Set() };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
events: [],
|
agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() },
|
||||||
eventIDs: new Set(),
|
stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} },
|
||||||
stats: {
|
dbStats: { messages: 0, tools: 0, errors: 0 },
|
||||||
messages: 0,
|
timerInterval: null,
|
||||||
tools: 0,
|
|
||||||
errors: 0,
|
|
||||||
toolCounts: {},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +824,68 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAgentBucket(evt) {
|
||||||
|
const name = getVMName(evt).toLowerCase();
|
||||||
|
return agentsState.agents[name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processAgentEvent(evt) {
|
||||||
|
const agent = getAgentBucket(evt);
|
||||||
|
if (!agent) return;
|
||||||
|
|
||||||
|
const eventType = getEnvelopeType(evt);
|
||||||
|
const correlation = getEnvelopeCorrelation(evt);
|
||||||
|
const attrs = getEnvelopeAttributes(evt);
|
||||||
|
|
||||||
|
if (eventType === 'session.start' && correlation.session_id) {
|
||||||
|
agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) };
|
||||||
|
}
|
||||||
|
if (eventType === 'session.end' && correlation.session_id) {
|
||||||
|
delete agent.sessions[correlation.session_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'span.start' && correlation.span_id) {
|
||||||
|
agent.operations['s:' + correlation.span_id] = {
|
||||||
|
type: 'span',
|
||||||
|
name: attrs.name || attrs.span_kind || 'unknown',
|
||||||
|
kind: attrs.span_kind || '',
|
||||||
|
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (eventType === 'span.end' && correlation.span_id) {
|
||||||
|
delete agent.operations['s:' + correlation.span_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'run.start' && correlation.run_id) {
|
||||||
|
agent.operations['r:' + correlation.run_id] = {
|
||||||
|
type: 'run',
|
||||||
|
name: 'Thinking…',
|
||||||
|
kind: 'run',
|
||||||
|
startedAt: new Date(getEnvelopeTS(evt)).getTime() || Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (eventType === 'run.end' && correlation.run_id) {
|
||||||
|
delete agent.operations['r:' + correlation.run_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRecordID(evt);
|
||||||
|
if (id && !agent.eventIDs.has(id)) {
|
||||||
|
agent.eventIDs.add(id);
|
||||||
|
agent.events.push(evt);
|
||||||
|
while (agent.events.length > 100) {
|
||||||
|
const removed = agent.events.shift();
|
||||||
|
agent.eventIDs.delete(getRecordID(removed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentDisplayOps(agent) {
|
||||||
|
const now = Date.now();
|
||||||
|
const ops = Object.values(agent.operations).filter(op => (now - op.startedAt) < 300000);
|
||||||
|
const hasTools = ops.some(op => op.kind === 'tool');
|
||||||
|
return hasTools ? ops.filter(op => op.kind === 'tool') : ops;
|
||||||
|
}
|
||||||
|
|
||||||
async function renderAgents() {
|
async function renderAgents() {
|
||||||
agentsState = createAgentsState();
|
agentsState = createAgentsState();
|
||||||
|
|
||||||
@@ -623,98 +893,177 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
<h2>Agents <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="vm-strip" id="agents-vm-strip"></div>
|
<div class="agents-summary-row" id="agents-summary"></div>
|
||||||
<div class="agents-layout">
|
<div class="agent-lanes" id="agents-lanes">
|
||||||
<div class="timeline" id="agents-timeline">
|
<div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">ZAP</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
|
||||||
<p class="empty-state">Loading agent activity...</p>
|
<div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">ORB</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
|
||||||
</div>
|
<div class="agent-lane"><div class="agent-lane-header"><div class="agent-lane-name">SUN</div></div><div class="agent-lane-events"><p class="empty-state">Loading...</p></div></div>
|
||||||
<div class="stats-panel">
|
|
||||||
<div class="stat-card" style="--card-accent:var(--accent)">
|
|
||||||
<div class="stat-card-title">Messages</div>
|
|
||||||
<div class="stat-card-value" id="stat-messages">0</div>
|
|
||||||
<div class="stat-card-sub">received and sent</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card" style="--card-accent:var(--purple)">
|
|
||||||
<div class="stat-card-title">Tool Calls</div>
|
|
||||||
<div class="stat-card-value" id="stat-tools">0</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card" style="--card-accent:var(--error)">
|
|
||||||
<div class="stat-card-title">Errors</div>
|
|
||||||
<div class="stat-card-value" id="stat-errors">0</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-card-title">Top Tools</div>
|
|
||||||
<ul class="stat-list" id="stat-top-tools">
|
|
||||||
<li style="color:var(--text-dim);font-size:0.8rem">No data yet</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
renderAgentVMStrip();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [snapshots, events] = await Promise.all([
|
const [snapshots, events, summaryData] = await Promise.all([
|
||||||
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
||||||
api('/v1/events?framework=openclaw&limit=200'),
|
api('/v1/events?framework=openclaw&limit=200'),
|
||||||
|
api('/v1/stats/summary').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isCurrentPath('/agents')) {
|
if (!isCurrentPath('/agents')) return;
|
||||||
return;
|
|
||||||
|
if (summaryData) {
|
||||||
|
const fw = (summaryData.by_framework || {}).openclaw || {};
|
||||||
|
agentsState.dbStats.messages = fw.runs || 0;
|
||||||
|
agentsState.dbStats.tools = fw.tools || 0;
|
||||||
|
agentsState.dbStats.errors = fw.errors || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeOpenClawEvents(snapshots.events || []);
|
mergeOpenClawEvents(snapshots.events || []);
|
||||||
renderAgentVMStrip();
|
|
||||||
addAgentEvents((events.events || []).slice().reverse());
|
addAgentEvents((events.events || []).slice().reverse());
|
||||||
renderAgentTimeline();
|
renderAgentLanes();
|
||||||
renderAgentStats();
|
renderAgentSummary();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const timeline = document.getElementById('agents-timeline');
|
document.getElementById('agents-lanes').innerHTML =
|
||||||
if (timeline) {
|
`<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
||||||
timeline.innerHTML = `<p class="empty-state">Error loading agent activity: ${escapeHTML(e.message)}</p>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentsState.timerInterval = setInterval(updateAgentTimers, 1000);
|
||||||
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
agentsUnsubscribe = subscribeWS(handleAgentsWS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentVMStrip() {
|
function renderAgentLanes() {
|
||||||
const strip = document.getElementById('agents-vm-strip');
|
const lanesEl = document.getElementById('agents-lanes');
|
||||||
if (!strip) {
|
if (!lanesEl) return;
|
||||||
return;
|
|
||||||
|
const vmNames = ['zap', 'orb', 'sun'];
|
||||||
|
|
||||||
|
lanesEl.innerHTML = vmNames.map(name => {
|
||||||
|
const agent = agentsState.agents[name];
|
||||||
|
const vmStatus = getVMStatus().find(v => v.name === name);
|
||||||
|
const isOnline = vmStatus && vmStatus.active;
|
||||||
|
const sessionCount = Object.keys(agent.sessions).length;
|
||||||
|
const ops = getAgentDisplayOps(agent);
|
||||||
|
|
||||||
|
const statusClass = sessionCount > 0 ? ' has-sessions' : '';
|
||||||
|
const statusText = !isOnline ? 'offline'
|
||||||
|
: sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '')
|
||||||
|
: 'idle';
|
||||||
|
|
||||||
|
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
|
||||||
|
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
|
||||||
|
const stale = elapsed > 300;
|
||||||
|
return `
|
||||||
|
<div class="active-op${stale ? ' stale' : ''}">
|
||||||
|
<span class="active-op-dot"></span>
|
||||||
|
<span class="active-op-name">${escapeHTML(op.name)}</span>
|
||||||
|
<span class="active-op-time" data-start="${op.startedAt}">${formatElapsed(elapsed)}</span>
|
||||||
|
${stale ? '<span class="active-op-stale">(stale?)</span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('')}</div>` : '';
|
||||||
|
|
||||||
|
const recent = agent.events.slice(-40).reverse();
|
||||||
|
const eventsHTML = recent.length > 0 ? recent.map(evt => {
|
||||||
|
const eventType = getEnvelopeType(evt);
|
||||||
|
const vmClass = getVMClassName(name);
|
||||||
|
const details = getEventDetails(evt);
|
||||||
|
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
||||||
|
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="timeline-event">
|
||||||
|
<div class="timeline-event-header">
|
||||||
|
${getEventIcon(eventType)}
|
||||||
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
||||||
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
||||||
|
</div>
|
||||||
|
${getEventBody(evt)}
|
||||||
|
${expandHTML}
|
||||||
|
${detailHTML}
|
||||||
|
</div>`;
|
||||||
|
}).join('') : '<p class="empty-state">No recent activity</p>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="agent-lane">
|
||||||
|
<div class="agent-lane-header">
|
||||||
|
<div class="agent-lane-name">
|
||||||
|
<span class="agent-lane-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||||
|
${escapeHTML(name.toUpperCase())}
|
||||||
|
</div>
|
||||||
|
<span class="agent-lane-status${statusClass}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
${opsHTML}
|
||||||
|
<div class="agent-lane-events">${eventsHTML}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
button.parentElement.classList.toggle('expanded');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const vms = getVMStatus();
|
function formatElapsed(seconds) {
|
||||||
strip.innerHTML = vms.map(vm => `
|
if (seconds < 60) return seconds + 's';
|
||||||
<div class="vm-pill ${vm.active ? 'active' : 'inactive'}">
|
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
|
||||||
<span class="vm-pill-dot"></span>
|
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
|
||||||
<span class="vm-pill-name">${escapeHTML(vm.name)}</span>
|
}
|
||||||
<span class="vm-pill-label">${vm.active ? 'online' : 'offline'}</span>
|
|
||||||
</div>
|
function renderAgentSummary() {
|
||||||
`).join('');
|
const el = document.getElementById('agents-summary');
|
||||||
|
if (!el) return;
|
||||||
|
const s = agentsState.dbStats;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="agents-summary-stat">Runs Today <span class="value">${s.messages}</span></div>
|
||||||
|
<div class="agents-summary-stat">Tool Calls <span class="value">${s.tools}</span></div>
|
||||||
|
<div class="agents-summary-stat">Errors <span class="value">${s.errors}</span></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentVMStrip() {
|
||||||
|
// VM online/offline state is shown in each lane header via getVMStatus().
|
||||||
|
// Re-render lanes to pick up the updated openclawState.
|
||||||
|
renderAgentLanes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAgentsWS(msg) {
|
function handleAgentsWS(msg) {
|
||||||
if (msg.type !== 'message') {
|
if (msg.type !== 'message') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventType = getEnvelopeType(msg.data);
|
const eventType = getEnvelopeType(msg.data);
|
||||||
if (eventType === 'openclaw.snapshot') {
|
if (eventType === 'openclaw.snapshot') {
|
||||||
mergeOpenClawEvents([msg.data]);
|
mergeOpenClawEvents([msg.data]);
|
||||||
renderAgentVMStrip();
|
renderAgentLanes();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
|
const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework;
|
||||||
if (framework !== 'openclaw') {
|
if (framework !== 'openclaw') return;
|
||||||
return;
|
|
||||||
}
|
if (eventType === 'run.start') agentsState.dbStats.messages++;
|
||||||
|
else if (eventType === 'span.end') {
|
||||||
|
const attrs = getEnvelopeAttributes(msg.data);
|
||||||
|
if (attrs.span_kind === 'tool') agentsState.dbStats.tools++;
|
||||||
|
} else if (eventType === 'error') agentsState.dbStats.errors++;
|
||||||
|
|
||||||
addAgentEvents([msg.data]);
|
addAgentEvents([msg.data]);
|
||||||
renderAgentTimeline();
|
renderAgentLanes();
|
||||||
renderAgentStats();
|
renderAgentSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAgentTimers() {
|
||||||
|
document.querySelectorAll('.active-op-time[data-start]').forEach(el => {
|
||||||
|
const start = parseInt(el.dataset.start, 10);
|
||||||
|
if (!start) return;
|
||||||
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||||
|
el.textContent = formatElapsed(elapsed);
|
||||||
|
|
||||||
|
const op = el.closest('.active-op');
|
||||||
|
if (op && elapsed > 300 && !op.classList.contains('stale')) {
|
||||||
|
op.classList.add('stale');
|
||||||
|
if (!op.querySelector('.active-op-stale')) {
|
||||||
|
op.insertAdjacentHTML('beforeend', '<span class="active-op-stale">(stale?)</span>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAgentEvents(events) {
|
function addAgentEvents(events) {
|
||||||
@@ -722,52 +1071,35 @@
|
|||||||
|
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
const id = getRecordID(evt);
|
const id = getRecordID(evt);
|
||||||
if (!id || agentsState.eventIDs.has(id)) {
|
const agent = getAgentBucket(evt);
|
||||||
continue;
|
if (!id || !agent || agent.eventIDs.has(id)) continue;
|
||||||
}
|
processAgentEvent(evt);
|
||||||
agentsState.eventIDs.add(id);
|
|
||||||
agentsState.events.push(evt);
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changed) {
|
if (changed) {
|
||||||
return;
|
for (const agent of Object.values(agentsState.agents)) {
|
||||||
|
agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
agentsState.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime());
|
|
||||||
|
|
||||||
while (agentsState.events.length > 500) {
|
|
||||||
const removed = agentsState.events.shift();
|
|
||||||
agentsState.eventIDs.delete(getRecordID(removed));
|
|
||||||
}
|
|
||||||
|
|
||||||
recomputeAgentStats();
|
recomputeAgentStats();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function recomputeAgentStats() {
|
function recomputeAgentStats() {
|
||||||
const stats = {
|
const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} };
|
||||||
messages: 0,
|
|
||||||
tools: 0,
|
|
||||||
errors: 0,
|
|
||||||
toolCounts: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const evt of agentsState.events) {
|
for (const agent of Object.values(agentsState.agents)) {
|
||||||
|
for (const evt of agent.events) {
|
||||||
const eventType = getEnvelopeType(evt);
|
const eventType = getEnvelopeType(evt);
|
||||||
const attrs = getEnvelopeAttributes(evt);
|
const attrs = getEnvelopeAttributes(evt);
|
||||||
|
|
||||||
if (eventType === 'run.start' || eventType === 'run.end') {
|
if (eventType === 'run.start' || eventType === 'run.end') stats.messages++;
|
||||||
stats.messages++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
|
if (eventType === 'span.end' && attrs.span_kind === 'tool') {
|
||||||
stats.tools++;
|
stats.tools++;
|
||||||
const toolName = attrs.name || 'unknown';
|
const toolName = attrs.name || 'unknown';
|
||||||
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
|
stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1;
|
||||||
}
|
}
|
||||||
|
if (eventType === 'error') stats.errors++;
|
||||||
if (eventType === 'error') {
|
|
||||||
stats.errors++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,88 +1214,6 @@
|
|||||||
return JSON.stringify(details, null, 2);
|
return JSON.stringify(details, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentTimeline() {
|
|
||||||
const timeline = document.getElementById('agents-timeline');
|
|
||||||
if (!timeline) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recent = agentsState.events.slice(-100).reverse();
|
|
||||||
if (recent.length === 0) {
|
|
||||||
timeline.innerHTML = '<p class="empty-state">Waiting for agent activity...</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
timeline.innerHTML = recent.map((evt, index) => {
|
|
||||||
const eventType = getEnvelopeType(evt);
|
|
||||||
const vmName = getVMName(evt);
|
|
||||||
const vmClass = getVMClassName(vmName);
|
|
||||||
const details = getEventDetails(evt);
|
|
||||||
const detailHTML = details ? `<div class="timeline-detail">${escapeHTML(details)}</div>` : '';
|
|
||||||
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="timeline-event" data-index="${index}">
|
|
||||||
<div class="timeline-event-header">
|
|
||||||
${getEventIcon(eventType)}
|
|
||||||
<span class="timeline-vm-tag ${vmClass}">${escapeHTML(vmName)}</span>
|
|
||||||
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
||||||
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
||||||
</div>
|
|
||||||
${getEventBody(evt)}
|
|
||||||
${expandHTML}
|
|
||||||
${detailHTML}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
timeline.querySelectorAll('.timeline-expand-hint').forEach(button => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
button.parentElement.classList.toggle('expanded');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAgentStats() {
|
|
||||||
const stats = agentsState.stats;
|
|
||||||
|
|
||||||
const messagesEl = document.getElementById('stat-messages');
|
|
||||||
if (messagesEl) {
|
|
||||||
messagesEl.textContent = String(stats.messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolsEl = document.getElementById('stat-tools');
|
|
||||||
if (toolsEl) {
|
|
||||||
toolsEl.textContent = String(stats.tools);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorsEl = document.getElementById('stat-errors');
|
|
||||||
if (errorsEl) {
|
|
||||||
errorsEl.textContent = String(stats.errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = document.getElementById('stat-top-tools');
|
|
||||||
if (!list) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topTools = Object.entries(stats.toolCounts)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 8);
|
|
||||||
|
|
||||||
if (topTools.length === 0) {
|
|
||||||
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No data yet</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = topTools.map(([name, count]) => `
|
|
||||||
<li>
|
|
||||||
<span class="stat-list-name">${escapeHTML(name)}</span>
|
|
||||||
<span class="stat-list-count">${count}</span>
|
|
||||||
</li>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderDashboard() {
|
async function renderDashboard() {
|
||||||
dashboardState = {
|
dashboardState = {
|
||||||
summary: null,
|
summary: null,
|
||||||
@@ -1001,7 +1251,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
||||||
<div class="vm-strip" id="dash-vm-strip" style="margin-bottom:1.5rem"></div>
|
<div class="vm-strip" id="dash-vm-strip"></div>
|
||||||
|
<div class="swarm-strip" id="dash-swarm-strip"></div>
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
<div class="chart-panel">
|
<div class="chart-panel">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
@@ -1062,32 +1313,48 @@
|
|||||||
|
|
||||||
renderDashVMStrip();
|
renderDashVMStrip();
|
||||||
|
|
||||||
|
// Render cached data immediately while the API call is in-flight
|
||||||
|
const cachedSummary = tryParseJSON(localStorage.getItem('agentmon:dash:summary'));
|
||||||
|
const cachedTS = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
|
||||||
|
if (cachedSummary) { dashboardState.summary = cachedSummary; renderSummaryCards(); }
|
||||||
|
if (cachedTS) { dashboardState.timeseries = cachedTS; renderTimeseriesChart(); renderFrameworkBars(); }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [summaryData, tsData, recentData, snapshots] = await Promise.all([
|
const [summaryData, tsData, recentData, snapshots, swarmSnaps, topToolsData] = await Promise.all([
|
||||||
api('/v1/stats/summary'),
|
api('/v1/stats/summary'),
|
||||||
api('/v1/stats/timeseries?window=1h'),
|
api('/v1/stats/timeseries?window=1h'),
|
||||||
api('/v1/events?limit=20'),
|
api('/v1/events?limit=20'),
|
||||||
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
||||||
|
api('/v1/events?event_type=swarm.snapshot&limit=10').catch(() => ({ events: [] })),
|
||||||
|
api('/v1/stats/top-tools').catch(() => ({ tools: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isCurrentPath('/')) return;
|
if (!isCurrentPath('/')) return;
|
||||||
|
|
||||||
mergeOpenClawEvents(snapshots.events || []);
|
mergeOpenClawEvents(snapshots.events || []);
|
||||||
renderDashVMStrip();
|
renderDashVMStrip();
|
||||||
|
for (const evt of swarmSnaps.events || []) mergeSwarmSnapshot(evt);
|
||||||
|
renderSwarmStrip_dash();
|
||||||
|
|
||||||
dashboardState.summary = summaryData;
|
dashboardState.summary = summaryData;
|
||||||
dashboardState.timeseries = tsData;
|
dashboardState.timeseries = tsData;
|
||||||
|
localStorage.setItem('agentmon:dash:summary', JSON.stringify(summaryData));
|
||||||
|
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(tsData));
|
||||||
renderSummaryCards();
|
renderSummaryCards();
|
||||||
renderTimeseriesChart();
|
renderTimeseriesChart();
|
||||||
renderFrameworkBars();
|
renderFrameworkBars();
|
||||||
|
|
||||||
|
// Seed tool counts from the dedicated top-tools endpoint
|
||||||
|
for (const t of (topToolsData.tools || [])) {
|
||||||
|
dashboardState.toolCounts[t.name] = t.count;
|
||||||
|
}
|
||||||
|
|
||||||
const events = (recentData.events || []).slice().reverse();
|
const events = (recentData.events || []).slice().reverse();
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
const id = getRecordID(evt);
|
const id = getRecordID(evt);
|
||||||
if (id && !dashboardState.recentEventIDs.has(id)) {
|
if (id && !dashboardState.recentEventIDs.has(id)) {
|
||||||
dashboardState.recentEventIDs.add(id);
|
dashboardState.recentEventIDs.add(id);
|
||||||
dashboardState.recentEvents.push(evt);
|
dashboardState.recentEvents.push(evt);
|
||||||
tallyTool(evt);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderDashFeed();
|
renderDashFeed();
|
||||||
@@ -1112,6 +1379,25 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSwarmStrip_dash() {
|
||||||
|
const strip = document.getElementById('dash-swarm-strip');
|
||||||
|
if (!strip) return;
|
||||||
|
const services = Object.values(swarmState.services);
|
||||||
|
if (services.length === 0) return;
|
||||||
|
strip.innerHTML = services.map(svc => {
|
||||||
|
const statusClass = svc.status === 'healthy' ? 'active'
|
||||||
|
: svc.status === 'degraded' ? 'degraded' : 'inactive';
|
||||||
|
const label = svc.status || 'unknown';
|
||||||
|
return `
|
||||||
|
<div class="vm-pill ${statusClass}">
|
||||||
|
<span class="vm-pill-dot"></span>
|
||||||
|
<span class="vm-pill-name">${escapeHTML(svc.name)}</span>
|
||||||
|
<span class="vm-pill-label">${escapeHTML(label)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function handleDashboardWS(msg) {
|
function handleDashboardWS(msg) {
|
||||||
if (msg.type !== 'message') return;
|
if (msg.type !== 'message') return;
|
||||||
|
|
||||||
@@ -1122,6 +1408,16 @@
|
|||||||
renderDashVMStrip();
|
renderDashVMStrip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (eventType === 'swarm.snapshot') {
|
||||||
|
mergeSwarmSnapshot(msg.data);
|
||||||
|
renderSwarmStrip_dash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventType === 'swarm.service.snapshot') {
|
||||||
|
mergeSwarmServiceSnapshot(msg.data);
|
||||||
|
renderSwarmStrip_dash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dashboardState.summary) {
|
if (dashboardState.summary) {
|
||||||
if (eventType === 'session.start') dashboardState.summary.active_sessions++;
|
if (eventType === 'session.start') dashboardState.summary.active_sessions++;
|
||||||
@@ -1200,9 +1496,12 @@
|
|||||||
dashboardChart.destroy();
|
dashboardChart.destroy();
|
||||||
dashboardChart = null;
|
dashboardChart = null;
|
||||||
}
|
}
|
||||||
|
const cachedWin = tryParseJSON(localStorage.getItem('agentmon:dash:ts:' + dashboardState.window));
|
||||||
|
if (cachedWin) { dashboardState.timeseries = cachedWin; renderTimeseriesChart(); }
|
||||||
const data = await api('/v1/stats/timeseries?window=' + dashboardState.window);
|
const data = await api('/v1/stats/timeseries?window=' + dashboardState.window);
|
||||||
if (!isCurrentPath('/')) return;
|
if (!isCurrentPath('/')) return;
|
||||||
dashboardState.timeseries = data;
|
dashboardState.timeseries = data;
|
||||||
|
localStorage.setItem('agentmon:dash:ts:' + dashboardState.window, JSON.stringify(data));
|
||||||
renderTimeseriesChart();
|
renderTimeseriesChart();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load timeseries:', e);
|
console.error('Failed to load timeseries:', e);
|
||||||
|
|||||||
+380
-10
@@ -30,6 +30,9 @@
|
|||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--radius-xl: 14px;
|
--radius-xl: 14px;
|
||||||
|
|
||||||
|
--header-bg: rgba(7, 9, 15, 0.82);
|
||||||
|
--code-text: #7a9ab5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reset ─────────────────────────────────────────────────── */
|
/* ── Reset ─────────────────────────────────────────────────── */
|
||||||
@@ -62,7 +65,7 @@ header {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
height: 54px;
|
height: 54px;
|
||||||
background: rgba(7, 9, 15, 0.82);
|
background: var(--header-bg);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -301,7 +304,7 @@ main > * {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -394,6 +397,15 @@ tr.clickable:hover td:first-child {
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Load more ─────────────────────────────────────────────── */
|
/* ── Load more ─────────────────────────────────────────────── */
|
||||||
.load-more {
|
.load-more {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -458,7 +470,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: #7a9ab5;
|
color: var(--code-text);
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -737,6 +749,22 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Swarm strip ──────────────────────────────────────────── */
|
||||||
|
.swarm-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pill.degraded {
|
||||||
|
border-color: rgba(251, 191, 36, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pill.degraded .vm-pill-dot {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -850,7 +878,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #7a9ab5;
|
color: var(--code-text);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
@@ -1068,9 +1096,9 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-card-value {
|
.summary-card-value {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-body);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -1300,10 +1328,17 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
top: -1px;
|
top: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fw-dot.openclaw { background: var(--accent); }
|
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
|
||||||
.fw-dot.claude-code { background: var(--success); }
|
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
||||||
.fw-dot.opencode { background: var(--purple); }
|
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
|
||||||
.fw-dot.unknown { background: var(--text-dim); }
|
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
|
||||||
|
.fw-dot.ended { opacity: 0.3; }
|
||||||
|
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes fwPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Meta tiles ───────────────────────────────────────────── */
|
/* ── Meta tiles ───────────────────────────────────────────── */
|
||||||
.meta-tiles {
|
.meta-tiles {
|
||||||
@@ -1350,3 +1385,338 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Light theme variables ────────────────────────────────── */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #edf0f5;
|
||||||
|
--card: rgba(255, 255, 255, 0.9);
|
||||||
|
--border: rgba(200, 210, 225, 0.8);
|
||||||
|
--border-soft: rgba(200, 210, 225, 0.5);
|
||||||
|
|
||||||
|
--text: #3d4a5c;
|
||||||
|
--text-dim: #8b9ab0;
|
||||||
|
--text-bright: #1a2332;
|
||||||
|
|
||||||
|
--accent: #0891b2;
|
||||||
|
--accent-dim: rgba(8, 145, 178, 0.08);
|
||||||
|
--accent-glow: rgba(8, 145, 178, 0.15);
|
||||||
|
|
||||||
|
--success: #059669;
|
||||||
|
--error: #dc2626;
|
||||||
|
--warning: #d97706;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
|
||||||
|
--header-bg: rgba(245, 247, 250, 0.92);
|
||||||
|
--code-text: #4a6580;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 80% 40% at 50% -20%, rgba(8, 145, 178, 0.04) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle at 1px 1px, rgba(8, 145, 178, 0.025) 1px, transparent 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html:not([data-theme]) {
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #edf0f5;
|
||||||
|
--card: rgba(255, 255, 255, 0.9);
|
||||||
|
--border: rgba(200, 210, 225, 0.8);
|
||||||
|
--border-soft: rgba(200, 210, 225, 0.5);
|
||||||
|
|
||||||
|
--text: #3d4a5c;
|
||||||
|
--text-dim: #8b9ab0;
|
||||||
|
--text-bright: #1a2332;
|
||||||
|
|
||||||
|
--accent: #0891b2;
|
||||||
|
--accent-dim: rgba(8, 145, 178, 0.08);
|
||||||
|
--accent-glow: rgba(8, 145, 178, 0.15);
|
||||||
|
|
||||||
|
--success: #059669;
|
||||||
|
--error: #dc2626;
|
||||||
|
--warning: #d97706;
|
||||||
|
--purple: #7c3aed;
|
||||||
|
|
||||||
|
--header-bg: rgba(245, 247, 250, 0.92);
|
||||||
|
--code-text: #4a6580;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not([data-theme]) body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 80% 40% at 50% -20%, rgba(8, 145, 178, 0.04) 0%, transparent 70%),
|
||||||
|
radial-gradient(circle at 1px 1px, rgba(8, 145, 178, 0.025) 1px, transparent 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme toggle button ──────────────────────────────────── */
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agent lanes ──────────────────────────────────────────── */
|
||||||
|
.agents-summary-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-summary-stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-summary-stat .value {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lanes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.agent-lanes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-dot.online {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
|
||||||
|
animation: livePulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-dot.offline {
|
||||||
|
background: var(--text-dim);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-status {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-status.has-sessions {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Active operations ────────────────────────────────────── */
|
||||||
|
.active-ops {
|
||||||
|
padding: 0.625rem 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.625rem;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.15);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
animation: fadeUp 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .active-op {
|
||||||
|
border-color: rgba(8, 145, 178, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op.stale {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 6px rgba(52, 211, 153, 0.5);
|
||||||
|
animation: livePulse 1.5s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op.stale .active-op-dot {
|
||||||
|
background: var(--warning);
|
||||||
|
box-shadow: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-op-stale {
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lane event feed ──────────────────────────────────────── */
|
||||||
|
.agent-lane-events {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
padding: 0.625rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-events::after {
|
||||||
|
content: '';
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--surface));
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-events .timeline-event {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-events .timeline-event-header {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-events .timeline-event-time {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-lane-events .empty-state {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile layout ────────────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
header {
|
||||||
|
height: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user