Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 206 additions & 23 deletions openclaw_security/dashboard/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,18 @@
color: var(--text-muted);
}

.toolbar select, .toolbar input[type="text"] {
.toolbar select, .toolbar input[type="text"], .toolbar input[type="datetime-local"] {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
font-family: 'JetBrains Mono', monospace;
color-scheme: dark;
}

.toolbar select:focus, .toolbar input[type="text"]:focus {
.toolbar select:focus, .toolbar input[type="text"]:focus, .toolbar input[type="datetime-local"]:focus {
outline: none;
border-color: var(--accent);
}
Expand Down Expand Up @@ -220,11 +221,55 @@
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
z-index: 1;
z-index: 2;
}

thead th:first-child { padding-left: 2rem; }

thead th.sortable {
cursor: pointer;
user-select: none;
}

thead th.sortable:hover { color: var(--text-secondary); }

thead th .sort-arrow {
display: inline-block;
margin-left: 0.3rem;
font-size: 0.6rem;
opacity: 0.3;
}

thead th.sort-asc .sort-arrow { opacity: 1; color: var(--accent); }
thead th.sort-desc .sort-arrow { opacity: 1; color: var(--accent); }

/* ── Column filter row ─────────────────────── */
thead tr.filter-row th {
position: sticky;
top: 28px;
background: var(--bg-card);
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border);
z-index: 1;
}

thead tr.filter-row th:first-child { padding-left: 1.75rem; }

.col-filter {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.7rem;
padding: 0.25rem 0.4rem;
font-family: 'JetBrains Mono', monospace;
width: 100%;
box-sizing: border-box;
}

.col-filter:focus { outline: none; border-color: var(--accent); }
.col-filter::placeholder { color: var(--text-muted); opacity: 0.5; }

tbody tr {
border-bottom: 1px solid var(--border-subtle);
transition: background 0.15s;
Expand Down Expand Up @@ -496,6 +541,10 @@ <h1>OpenClaw <span>Security</span></h1>
<option value="message.before">message.before</option>
</select>
<input type="text" id="filterSearch" placeholder="Search session, tool..." style="width: 180px;">
<label>From</label>
<input type="datetime-local" id="filterStart" step="1">
<label>To</label>
<input type="datetime-local" id="filterEnd" step="1">
<div class="spacer"></div>
<span style="font-size: 0.75rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace;" id="eventCount">0 events</span>
<label style="display: flex; align-items: center; gap: 0.4rem; cursor: pointer;">
Expand All @@ -506,14 +555,23 @@ <h1>OpenClaw <span>Security</span></h1>
<div class="table-wrap" id="tableWrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Stage</th>
<th>Tool</th>
<th>Session</th>
<th>Reason</th>
<th style="text-align: right;">Latency</th>
<tr id="headerRow">
<th class="sortable" data-col="timestamp">Date and Time <span class="sort-arrow">&#9650;</span></th>
<th class="sortable" data-col="action">Action <span class="sort-arrow">&#9650;</span></th>
<th class="sortable" data-col="stage">Stage <span class="sort-arrow">&#9650;</span></th>
<th class="sortable" data-col="tool_name">Tool <span class="sort-arrow">&#9650;</span></th>
<th class="sortable" data-col="session_id">Session <span class="sort-arrow">&#9650;</span></th>
<th class="sortable" data-col="reasons">Reason <span class="sort-arrow">&#9650;</span></th>
<th class="sortable" data-col="elapsed_ms" style="text-align: right;">Latency <span class="sort-arrow">&#9650;</span></th>
</tr>
<tr class="filter-row">
<th><input class="col-filter" data-col="timestamp" type="text" placeholder="Filter..."></th>
<th><input class="col-filter" data-col="action" type="text" placeholder="Filter..."></th>
<th><input class="col-filter" data-col="stage" type="text" placeholder="Filter..."></th>
<th><input class="col-filter" data-col="tool_name" type="text" placeholder="Filter..."></th>
<th><input class="col-filter" data-col="session_id" type="text" placeholder="Filter..."></th>
<th><input class="col-filter" data-col="reasons" type="text" placeholder="Filter..."></th>
<th><input class="col-filter" data-col="elapsed_ms" type="text" placeholder="Filter..." style="text-align: right;"></th>
</tr>
</thead>
<tbody id="eventBody">
Expand Down Expand Up @@ -551,10 +609,16 @@ <h2>
const filterActionChecks = document.querySelectorAll('input[name="filterAction"]');
const filterStage = document.getElementById('filterStage');
const filterSearch = document.getElementById('filterSearch');
const filterStart = document.getElementById('filterStart');
const filterEnd = document.getElementById('filterEnd');
const autoScroll = document.getElementById('autoScroll');
const colFilters = document.querySelectorAll('.col-filter');
const headerRow = document.getElementById('headerRow');

let allEvents = [];
let selectedEvent = null;
let sortCol = null; // current sort column key
let sortDir = 0; // 0=none, 1=asc, -1=desc

// ── Stats ─────────────────────────────────────
function updateStats(stats) {
Expand All @@ -568,8 +632,10 @@ <h2>
// ── Rendering ─────────────────────────────────
function formatTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
const date = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const time = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
+ '.' + String(d.getMilliseconds()).padStart(3, '0');
return date + ' ' + time;
}

function badgeClass(action) {
Expand All @@ -582,19 +648,80 @@ <h2>
return checked;
}

function matchesFilter(ev) {
const checked = getCheckedActions();
if (checked.length > 0 && !checked.includes(ev.action)) return false;
const fs = filterStage.value;
function getColFilterValues() {
const vals = {};
colFilters.forEach(input => {
const v = input.value.trim().toLowerCase();
if (v) vals[input.dataset.col] = v;
});
return vals;
}

function getColText(ev, col) {
switch (col) {
case 'timestamp': return formatTime(ev.timestamp);
case 'action': return ev.action || '';
case 'stage': return ev.stage || '';
case 'tool_name': return ev.tool_name || '';
case 'session_id': return ev.session_id || '';
case 'reasons': return ev.reasons.join('; ');
case 'elapsed_ms': return ev.elapsed_ms != null ? String(ev.elapsed_ms) : '';
default: return '';
}
}

function matchesClientFilter(ev) {
// Client-side filters: search box + column filters.
// Action, stage, and datetime are handled server-side.
const q = filterSearch.value.toLowerCase();
if (fs && ev.stage !== fs) return false;
if (q) {
const haystack = [ev.session_id, ev.tool_name, ...ev.reasons].join(' ').toLowerCase();
if (!haystack.includes(q)) return false;
}
const colVals = getColFilterValues();
for (const col in colVals) {
if (!getColText(ev, col).toLowerCase().includes(colVals[col])) return false;
}
return true;
}

function matchesSSEFilter(ev) {
// Check if an incoming SSE event matches current server-side filters
// so we can append it without a re-fetch.
const checked = getCheckedActions();
if (checked.length > 0 && !checked.includes(ev.action)) return false;
const fs = filterStage.value;
if (fs && ev.stage !== fs) return false;
if (filterStart.value) {
const startTs = new Date(filterStart.value).getTime() / 1000;
if (ev.timestamp < startTs) return false;
}
if (filterEnd.value) {
const endTs = new Date(filterEnd.value).getTime() / 1000;
if (ev.timestamp > endTs) return false;
}
return matchesClientFilter(ev);
}

function getSortValue(ev, col) {
switch (col) {
case 'timestamp': return ev.timestamp;
case 'elapsed_ms': return ev.elapsed_ms || 0;
default: return getColText(ev, col).toLowerCase();
}
}

function sortEvents(events) {
if (!sortCol || sortDir === 0) return events;
return events.slice().sort((a, b) => {
const va = getSortValue(a, sortCol);
const vb = getSortValue(b, sortCol);
if (va < vb) return -1 * sortDir;
if (va > vb) return 1 * sortDir;
return 0;
});
}

function renderRow(ev, flash) {
const tr = document.createElement('tr');
if (flash) tr.classList.add('flash');
Expand All @@ -614,14 +741,14 @@ <h2>

function renderAll() {
tbody.innerHTML = '';
const visible = allEvents.filter(matchesFilter);
const visible = sortEvents(allEvents.filter(matchesClientFilter));
visible.forEach(ev => tbody.appendChild(renderRow(ev, false)));
emptyState.style.display = visible.length === 0 ? 'flex' : 'none';
eventCount.textContent = visible.length + ' event' + (visible.length !== 1 ? 's' : '');
}

function addEventRow(ev) {
if (!matchesFilter(ev)) return;
if (!matchesSSEFilter(ev)) return;
emptyState.style.display = 'none';
const tr = renderRow(ev, true);
tbody.appendChild(tr);
Expand Down Expand Up @@ -678,15 +805,69 @@ <h2>
document.getElementById('closeDetail').onclick = () => detailOverlay.classList.remove('open');
detailOverlay.onclick = (e) => { if (e.target === detailOverlay) detailOverlay.classList.remove('open'); };

// ── Column sorting ─────────────────────────────
function updateSortUI() {
headerRow.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
const arrow = th.querySelector('.sort-arrow');
if (th.dataset.col === sortCol && sortDir === 1) {
th.classList.add('sort-asc');
arrow.innerHTML = '&#9650;';
} else if (th.dataset.col === sortCol && sortDir === -1) {
th.classList.add('sort-desc');
arrow.innerHTML = '&#9660;';
} else {
arrow.innerHTML = '&#9650;';
}
});
}

headerRow.querySelectorAll('th.sortable').forEach(th => {
th.onclick = () => {
const col = th.dataset.col;
if (sortCol === col) {
// cycle: asc -> desc -> none
if (sortDir === 1) sortDir = -1;
else if (sortDir === -1) { sortDir = 0; sortCol = null; }
else sortDir = 1;
} else {
sortCol = col;
sortDir = 1;
}
updateSortUI();
renderAll();
};
});

// ── Filters ───────────────────────────────────
filterActionChecks.forEach(cb => { cb.onchange = renderAll; });
filterStage.onchange = renderAll;
// Server-side filters trigger a re-fetch; client-side filters just re-render.
filterActionChecks.forEach(cb => { cb.onchange = fetchAndRender; });
filterStage.onchange = fetchAndRender;
filterStart.onchange = fetchAndRender;
filterEnd.onchange = fetchAndRender;
filterSearch.oninput = renderAll;
colFilters.forEach(input => { input.oninput = renderAll; });

// ── Load history ──────────────────────────────
async function loadHistory() {
function buildHistoryURL() {
const params = new URLSearchParams();
params.set('limit', '500');
const checked = getCheckedActions();
checked.forEach(a => params.append('actions', a));
const stage = filterStage.value;
if (stage) params.set('stage', stage);
if (filterStart.value) {
params.set('start_ts', String(new Date(filterStart.value).getTime() / 1000));
}
if (filterEnd.value) {
params.set('end_ts', String(new Date(filterEnd.value).getTime() / 1000));
}
return '/dashboard/api/history?' + params.toString();
}

async function fetchAndRender() {
try {
const resp = await fetch('/dashboard/api/history?limit=500');
const resp = await fetch(buildHistoryURL());
const data = await resp.json();
allEvents = (data.events || []).reverse(); // oldest first
updateStats(data.stats || {});
Expand All @@ -696,6 +877,8 @@ <h2>
}
}

const loadHistory = fetchAndRender;

// ── SSE connection ────────────────────────────
function connectSSE() {
const es = new EventSource('/dashboard/events');
Expand Down
15 changes: 13 additions & 2 deletions openclaw_security/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,22 @@ async def history(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
action: str | None = Query(None),
actions: list[str] | None = Query(None),
stage: str | None = Query(None),
start_ts: float | None = Query(None),
end_ts: float | None = Query(None),
):
"""Return paginated event history."""
"""Return paginated event history with server-side filtering."""
store = _get_store(request)
events = store.history(limit=limit, offset=offset, action=action, stage=stage)
events = store.history(
limit=limit,
offset=offset,
action=action,
actions=actions,
stage=stage,
start_ts=start_ts,
end_ts=end_ts,
)
return {"events": events, "stats": store.stats()}


Expand Down
16 changes: 15 additions & 1 deletion openclaw_security/dashboard/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,30 @@ def history(
limit: int = 50,
offset: int = 0,
action: str | None = None,
actions: list[str] | None = None,
stage: str | None = None,
start_ts: float | None = None,
end_ts: float | None = None,
) -> list[dict[str, Any]]:
"""Return recent events with optional filters."""
"""Return recent events with optional filters.

Filters are applied *before* the limit/offset slice so that
the caller always gets up to ``limit`` matching events.
"""
events = list(self._events)
events.reverse() # newest first

if action:
events = [e for e in events if e.action == action]
elif actions:
action_set = set(actions)
events = [e for e in events if e.action in action_set]
if stage:
events = [e for e in events if e.stage == stage]
if start_ts is not None:
events = [e for e in events if e.timestamp >= start_ts]
if end_ts is not None:
events = [e for e in events if e.timestamp <= end_ts]

return [e.to_dict() for e in events[offset : offset + limit]]

Expand Down
Loading