feat(web-ui): add service health graphs and core log viewer

This commit is contained in:
William Valentin
2026-02-22 20:54:43 -08:00
parent ca463d5ca2
commit 0a5972a732
4 changed files with 614 additions and 1 deletions
+359 -1
View File
@@ -22,6 +22,20 @@ let _lastCouncilError = null;
let _lastServices = [];
let _lastLocalBackends = [];
let _lastDockerDependencies = [];
let _lastObservabilitySources = [];
let _lastObservabilitySeries = null;
let _lastServiceLogs = null;
let _selectedLogSourceId = null;
let _logRefreshTimer = null;
let _logViewerState = {
lines: 200,
sinceSeconds: 900,
level: 'all',
autoRefresh: true,
paused: false,
status: null,
tone: 'neutral',
};
let _localBackendActionState = new Map();
let _dockerDependencyActionState = new Map();
let _serviceConfigState = {
@@ -47,6 +61,14 @@ const DOCKER_DEPENDENCY_ACTION_LABELS = {
stop: 'Stop',
update: 'Update',
};
const LOG_LEVEL_OPTIONS = ['all', 'info', 'warn', 'error'];
const LOG_LINES_OPTIONS = [100, 200, 500, 1000];
const LOG_SINCE_OPTIONS = [
{ value: 300, label: '5m' },
{ value: 900, label: '15m' },
{ value: 3600, label: '1h' },
{ value: 21600, label: '6h' },
];
const SERVICE_TOGGLE_PATCH_PATHS = {
heartbeat: 'automation.heartbeat.enabled',
daily_briefing: 'automation.daily_briefing.enabled',
@@ -92,6 +114,13 @@ function formatDay(day) {
return parsed.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
}
function formatLogTime(timestamp) {
if (!timestamp || !Number.isFinite(Number(timestamp))) {
return '--:--:--';
}
return new Date(Number(timestamp)).toLocaleTimeString('en-GB', { hour12: false });
}
function formatNumber(value) {
return (value ?? 0).toLocaleString();
}
@@ -400,6 +429,18 @@ function renderSkeleton(el) {
<div id="ops-docker-dependencies" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Service Health Graphs</h2>
<div class="text-xs text-zinc-500 mb-2">Bounded 1h trend view (30s buckets) for compose dependencies and systemd daemons.</div>
<div id="ops-observability-graphs" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Service Logs</h2>
<div class="text-xs text-zinc-500 mb-2">Recent core service logs with server-side token masking.</div>
<div id="ops-service-logs" class="bg-zinc-900 border border-zinc-800 rounded-lg p-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<div id="ops-service-config-modal-root"></div>
`;
}
@@ -1436,6 +1477,16 @@ function updateAssistantHealth(configData) {
updateServices(refreshed.services);
updateLocalBackends(refreshed.localBackends);
updateDockerDependencies(refreshed.dockerDependencies);
if (refreshed.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
}
if (refreshed.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
updateServiceLogsPanel();
await refreshServiceLogs({ force: true });
updateSessionAnalytics(refreshed.sessionAnalytics);
updateContextHealth(refreshed.contextUsage);
// Only re-render assistant controls from a confirmed config snapshot.
@@ -1700,6 +1751,226 @@ function updateDockerDependencies(dockerDependenciesData) {
});
}
function getObservabilityName(sourceId) {
const source = _lastObservabilitySources.find((entry) => entry.id === sourceId);
return source?.name ?? sourceId;
}
function buildSparklinePath(points, key, width = 320, height = 56) {
if (!Array.isArray(points) || points.length === 0) {
return null;
}
const values = points.map((point) => Number(point?.[key] ?? 0));
const min = Math.min(...values);
const max = Math.max(...values);
const denom = max === min ? 1 : (max - min);
return values.map((value, idx) => {
const x = values.length === 1 ? 0 : (idx / (values.length - 1)) * width;
const y = height - (((value - min) / denom) * height);
return `${idx === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
}).join(' ');
}
function updateObservabilityGraphs(seriesData) {
const el = document.getElementById('ops-observability-graphs');
if (!el) {return;}
const series = Array.isArray(seriesData?.series) ? seriesData.series : [];
_lastObservabilitySeries = seriesData ?? null;
if (series.length === 0) {
el.innerHTML = '<div class="text-sm text-zinc-500">No service trend data available yet</div>';
return;
}
const cards = [...series]
.sort((a, b) => getObservabilityName(String(a.sourceId)).localeCompare(getObservabilityName(String(b.sourceId))))
.map((entry) => {
const sourceId = String(entry.sourceId ?? '');
const source = _lastObservabilitySources.find((candidate) => candidate.id === sourceId);
const name = source?.name ?? sourceId;
const status = String(source?.status ?? 'unknown');
const points = Array.isArray(entry.points) ? entry.points : [];
const latest = points.length > 0 ? points[points.length - 1] : null;
const first = points.length > 0 ? points[0] : null;
const restartDelta = latest && first ? Math.max(0, Number(latest.restartCount ?? 0) - Number(first.restartCount ?? 0)) : 0;
const errorDelta = latest && first ? Math.max(0, Number(latest.errorCount ?? 0) - Number(first.errorCount ?? 0)) : 0;
const statePath = buildSparklinePath(points, 'stateCode');
const healthPath = buildSparklinePath(points, 'healthCode');
const statusColor = status === 'running'
? 'text-green-400'
: status === 'degraded'
? 'text-amber-400'
: status === 'stopped'
? 'text-zinc-400'
: 'text-red-400';
return `<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold text-zinc-50">${escapeHtml(name)}</div>
<span class="text-xs uppercase ${statusColor}">${escapeHtml(status)}</span>
</div>
<div class="text-xs text-zinc-500">Source: <span class="font-mono text-zinc-400">${escapeHtml(sourceId)}</span></div>
<div class="text-xs text-zinc-500">Window deltas: <span class="font-mono text-zinc-400">restarts ${restartDelta}</span> · <span class="font-mono text-zinc-400">errors ${errorDelta}</span></div>
<div class="bg-zinc-950 border border-zinc-800 rounded p-2">
${statePath
? `<svg viewBox="0 0 320 56" class="w-full h-14">
<path d="${statePath}" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" />
${healthPath ? `<path d="${healthPath}" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="5 4" stroke-linecap="round" />` : ''}
</svg>`
: '<div class="text-xs text-zinc-500">No sample points yet</div>'}
<div class="text-[11px] text-zinc-500 mt-1">Blue: state code · Green dashed: health code</div>
</div>
</div>`;
});
el.innerHTML = cards.join('');
}
function normalizeLogSourceSelection() {
const logSources = _lastObservabilitySources.filter((source) => source.logCapable);
if (logSources.length === 0) {
_selectedLogSourceId = null;
return [];
}
if (!_selectedLogSourceId || !logSources.some((source) => source.id === _selectedLogSourceId)) {
_selectedLogSourceId = logSources[0].id;
}
return logSources;
}
function updateServiceLogsPanel() {
const el = document.getElementById('ops-service-logs');
if (!el) {return;}
const logSources = normalizeLogSourceSelection();
if (logSources.length === 0) {
el.innerHTML = '<div class="text-sm text-zinc-500">No log-capable services currently available</div>';
return;
}
const snapshot = _lastServiceLogs;
const rawLines = Array.isArray(snapshot?.lines) ? snapshot.lines : [];
const filterLevel = _logViewerState.level;
const lines = rawLines.filter((line) => filterLevel === 'all' || String(line.level ?? 'info') === filterLevel);
const statusToneClass = _logViewerState.tone === 'error'
? 'text-red-400'
: _logViewerState.tone === 'success'
? 'text-green-400'
: 'text-zinc-500';
const redactedBadge = snapshot?.redacted
? '<span class="px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs">redacted</span>'
: '<span class="px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700 text-xs">raw-safe</span>';
const linesHtml = lines.length > 0
? lines.map((entry) => {
const level = String(entry.level ?? 'info');
const levelClass = level === 'error' ? 'text-red-400' : level === 'warn' ? 'text-amber-400' : 'text-zinc-400';
const ts = formatLogTime(entry.ts);
return `<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words"><span class="font-mono text-zinc-500">[${escapeHtml(ts)}]</span> <span class="uppercase ${levelClass}">${escapeHtml(level)}</span> <span class="text-zinc-200">${escapeHtml(String(entry.text ?? ''))}</span></div>`;
}).join('')
: '<div class="px-2 py-1 text-zinc-500">No log lines for current filters</div>';
el.innerHTML = `
<div class="flex flex-wrap items-center gap-2 mb-3">
<label class="text-xs text-zinc-400">Service</label>
<select id="ops-log-source" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${logSources.map((source) => `<option value="${escapeHtml(source.id)}" ${source.id === _selectedLogSourceId ? 'selected' : ''}>${escapeHtml(source.name)}</option>`).join('')}
</select>
<label class="text-xs text-zinc-400">Window</label>
<select id="ops-log-since" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${LOG_SINCE_OPTIONS.map((opt) => `<option value="${opt.value}" ${Number(_logViewerState.sinceSeconds) === opt.value ? 'selected' : ''}>${opt.label}</option>`).join('')}
</select>
<label class="text-xs text-zinc-400">Lines</label>
<select id="ops-log-lines-select" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${LOG_LINES_OPTIONS.map((opt) => `<option value="${opt}" ${Number(_logViewerState.lines) === opt ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
<label class="text-xs text-zinc-400">Level</label>
<select id="ops-log-level" class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-100">
${LOG_LEVEL_OPTIONS.map((opt) => `<option value="${opt}" ${_logViewerState.level === opt ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
<label class="text-xs text-zinc-300 flex items-center gap-1"><input id="ops-log-auto" type="checkbox" ${_logViewerState.autoRefresh ? 'checked' : ''}/>auto</label>
<label class="text-xs text-zinc-300 flex items-center gap-1"><input id="ops-log-pause" type="checkbox" ${_logViewerState.paused ? 'checked' : ''}/>pause</label>
<button id="ops-log-refresh" class="px-2 py-1 text-xs border border-zinc-700 rounded text-zinc-200 hover:bg-zinc-800">Refresh</button>
${redactedBadge}
</div>
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-zinc-500">${snapshot?.fetchedAt ? `Last fetch ${escapeHtml(formatLogTime(snapshot.fetchedAt))}` : 'No logs fetched yet'}</div>
<div class="text-xs ${statusToneClass}">${escapeHtml(String(_logViewerState.status ?? ''))}</div>
</div>
<div id="ops-log-lines-view" class="max-h-80 overflow-y-auto bg-zinc-950 border border-zinc-800 rounded p-2 font-mono text-xs">${linesHtml}</div>
`;
el.querySelector('#ops-log-source')?.addEventListener('change', async (event) => {
_selectedLogSourceId = event.target.value;
await refreshServiceLogs({ force: true });
});
el.querySelector('#ops-log-since')?.addEventListener('change', async (event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed) && parsed > 0) {
_logViewerState.sinceSeconds = parsed;
}
await refreshServiceLogs({ force: true });
});
el.querySelector('#ops-log-lines-select')?.addEventListener('change', async (event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed) && parsed > 0) {
_logViewerState.lines = parsed;
}
await refreshServiceLogs({ force: true });
});
el.querySelector('#ops-log-level')?.addEventListener('change', (event) => {
_logViewerState.level = event.target.value;
updateServiceLogsPanel();
});
el.querySelector('#ops-log-auto')?.addEventListener('change', (event) => {
_logViewerState.autoRefresh = Boolean(event.target.checked);
if (_logViewerState.autoRefresh && !_logViewerState.paused) {
void refreshServiceLogs({ force: true });
}
});
el.querySelector('#ops-log-pause')?.addEventListener('change', (event) => {
_logViewerState.paused = Boolean(event.target.checked);
});
el.querySelector('#ops-log-refresh')?.addEventListener('click', async () => {
await refreshServiceLogs({ force: true });
});
}
async function refreshServiceLogs(opts = {}) {
if (!_dashboardClient || !_selectedLogSourceId) {return;}
const force = opts.force === true;
if (!force && (!_logViewerState.autoRefresh || _logViewerState.paused)) {
return;
}
try {
const result = await _dashboardClient.call('system.serviceLogs', {
sourceId: _selectedLogSourceId,
lines: _logViewerState.lines,
sinceSeconds: _logViewerState.sinceSeconds,
});
_lastServiceLogs = result;
_logViewerState.status = `Fetched ${Array.isArray(result?.lines) ? result.lines.length : 0} line(s)`;
_logViewerState.tone = 'success';
} catch (error) {
_logViewerState.status = `Log fetch failed: ${error instanceof Error ? error.message : String(error)}`;
_logViewerState.tone = 'error';
}
updateServiceLogsPanel();
}
async function handleLocalBackendAction(backendId, action) {
if (!_dashboardClient) {return;}
const actionLabel = LOCAL_BACKEND_ACTION_LABELS[action] ?? action;
@@ -1731,6 +2002,16 @@ async function handleLocalBackendAction(backendId, action) {
if (refreshed?.dockerDependencies) {
updateDockerDependencies(refreshed.dockerDependencies);
}
if (refreshed?.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
updateServiceLogsPanel();
}
if (refreshed?.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
await refreshServiceLogs({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
_localBackendActionState.set(backendId, {
@@ -1773,6 +2054,16 @@ async function handleDockerDependencyAction(dependencyId, action) {
if (refreshed?.dockerDependencies) {
updateDockerDependencies(refreshed.dockerDependencies);
}
if (refreshed?.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
updateServiceLogsPanel();
}
if (refreshed?.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
await refreshServiceLogs({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
_dockerDependencyActionState.set(dependencyId, {
@@ -1956,6 +2247,16 @@ function renderServiceConfigModal() {
updateServices(refreshed.services);
updateLocalBackends(refreshed.localBackends);
updateDockerDependencies(refreshed.dockerDependencies);
if (refreshed.observabilitySources) {
_lastObservabilitySources = Array.isArray(refreshed.observabilitySources.sources)
? refreshed.observabilitySources.sources
: [];
}
if (refreshed.observabilitySeries) {
updateObservabilityGraphs(refreshed.observabilitySeries);
}
updateServiceLogsPanel();
await refreshServiceLogs({ force: true });
updateSessionAnalytics(refreshed.sessionAnalytics);
updateContextHealth(refreshed.contextUsage);
if (refreshed.config) {
@@ -1988,11 +2289,24 @@ async function fetchFast(client) {
}
async function fetchSlow(client) {
const [health, services, localBackends, dockerDependencies, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([
const [
health,
services,
localBackends,
dockerDependencies,
observabilitySources,
observabilitySeries,
sessionAnalytics,
contextUsage,
config,
modelCatalog,
] = await Promise.allSettled([
client.call('system.health'),
client.call('system.services'),
client.call('system.localBackends'),
client.call('system.dockerDependencies'),
client.call('system.observabilitySources'),
client.call('system.observabilitySeries', { windowMinutes: 60, bucketSeconds: 30 }),
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
client.call('system.contextUsage'),
client.call('config.get'),
@@ -2012,6 +2326,8 @@ async function fetchSlow(client) {
services: unwrap(services),
localBackends: unwrap(localBackends),
dockerDependencies: unwrap(dockerDependencies),
observabilitySources: unwrap(observabilitySources),
observabilitySeries: unwrap(observabilitySeries),
sessionAnalytics: unwrap(sessionAnalytics),
contextUsage: unwrap(contextUsage),
config: configValue,
@@ -2054,6 +2370,18 @@ async function loadDashboard(el, client) {
if (slow?.dockerDependencies) {
updateDockerDependencies(slow.dockerDependencies);
}
if (slow?.observabilitySources) {
_lastObservabilitySources = Array.isArray(slow.observabilitySources.sources)
? slow.observabilitySources.sources
: [];
}
if (slow?.observabilitySeries) {
updateObservabilityGraphs(slow.observabilitySeries);
} else {
updateObservabilityGraphs({ series: [] });
}
updateServiceLogsPanel();
await refreshServiceLogs({ force: true });
if (slow?.sessionAnalytics) {
updateSessionAnalytics(slow.sessionAnalytics);
}
@@ -2095,6 +2423,15 @@ async function loadDashboard(el, client) {
if (data.dockerDependencies) {
updateDockerDependencies(data.dockerDependencies);
}
if (data.observabilitySources) {
_lastObservabilitySources = Array.isArray(data.observabilitySources.sources)
? data.observabilitySources.sources
: [];
updateServiceLogsPanel();
}
if (data.observabilitySeries) {
updateObservabilityGraphs(data.observabilitySeries);
}
if (data.sessionAnalytics) {
updateSessionAnalytics(data.sessionAnalytics);
}
@@ -2105,6 +2442,10 @@ async function loadDashboard(el, client) {
updateAssistantHealth(_lastAssistantConfig);
}
}, 10000);
_logRefreshTimer = setInterval(async () => {
await refreshServiceLogs();
}, 5000);
}
export const DashboardPage = {
@@ -2121,6 +2462,10 @@ export const DashboardPage = {
clearInterval(_slowTimer);
_slowTimer = null;
}
if (_logRefreshTimer) {
clearInterval(_logRefreshTimer);
_logRefreshTimer = null;
}
_lastHealth = null;
_lastMetrics = null;
_dashboardClient = null;
@@ -2135,6 +2480,19 @@ export const DashboardPage = {
_lastServices = [];
_lastLocalBackends = [];
_lastDockerDependencies = [];
_lastObservabilitySources = [];
_lastObservabilitySeries = null;
_lastServiceLogs = null;
_selectedLogSourceId = null;
_logViewerState = {
lines: 200,
sinceSeconds: 900,
level: 'all',
autoRefresh: true,
paused: false,
status: null,
tone: 'neutral',
};
_localBackendActionState = new Map();
_dockerDependencyActionState = new Map();
_serviceConfigState = {
+120
View File
@@ -149,6 +149,68 @@ function createMockClient() {
availableActions: ['restart', 'stop', 'update'],
},
],
observabilitySources: [
{
id: 'systemd:flynn',
name: 'Flynn daemon',
kind: 'systemd_system',
runtime: 'systemd_system',
status: 'running',
graphCapable: true,
logCapable: true,
},
{
id: 'docker:whisper',
name: 'Whisper (whisper.cpp)',
kind: 'docker_dependency',
runtime: 'docker_compose',
status: 'running',
graphCapable: true,
logCapable: true,
},
],
observabilitySeries: {
generatedAt: 1700000000000,
windowMinutes: 60,
bucketSeconds: 30,
series: [
{
sourceId: 'systemd:flynn',
points: [
{ ts: 1700000000000, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 0 },
{ ts: 1700000030000, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 1 },
],
},
{
sourceId: 'docker:whisper',
points: [
{ ts: 1700000000000, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 0 },
{ ts: 1700000030000, stateCode: 2, healthCode: 1, errorCount: 1, restartCount: 0 },
],
},
],
},
serviceLogs: {
'systemd:flynn': {
sourceId: 'systemd:flynn',
fetchedAt: 1700000005000,
redacted: true,
truncated: false,
lines: [
{ ts: 1700000004000, level: 'info', text: 'gateway online' },
{ ts: 1700000004500, level: 'warn', text: 'queue depth rising' },
],
},
'docker:whisper': {
sourceId: 'docker:whisper',
fetchedAt: 1700000006000,
redacted: false,
truncated: false,
lines: [
{ ts: 1700000005500, level: 'error', text: 'model load failed' },
],
},
} as Record<string, unknown>,
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
};
@@ -199,6 +261,22 @@ function createMockClient() {
averageMessagesPerSession: 0,
};
}
if (method === 'system.observabilitySources') {
return { sources: deepClone(state.observabilitySources) };
}
if (method === 'system.observabilitySeries') {
return deepClone(state.observabilitySeries);
}
if (method === 'system.serviceLogs') {
const sourceId = String(params?.sourceId ?? '');
return deepClone(state.serviceLogs[sourceId] ?? {
sourceId,
fetchedAt: Date.now(),
redacted: false,
truncated: false,
lines: [],
});
}
if (method === 'system.contextUsage') {
return { sessions: [] };
}
@@ -590,4 +668,46 @@ describe('DashboardPage assistant controls', () => {
expect(state.dockerDependencies.find((entry) => entry.id === 'whisper')?.state).toBe('running');
expect(state.dockerDependencies.find((entry) => entry.id === 'brave-search')?.state).toBe('running');
});
it('renders observability graph cards with sampled trend data', async () => {
const { client } = createMockClient();
await DashboardPage.render(container, client);
const card = container.querySelector('#ops-observability-graphs');
expect(card).toBeTruthy();
expect(String(card.textContent ?? '')).toContain('Flynn daemon');
expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)');
expect(String(card.textContent ?? '')).toContain('Window deltas');
});
it('renders service logs and refreshes selected source', async () => {
const { state, client } = createMockClient();
await DashboardPage.render(container, client);
const logsPanel = container.querySelector('#ops-service-logs');
expect(logsPanel).toBeTruthy();
expect(String(logsPanel.textContent ?? '')).toContain('gateway online');
const sourceSelect = container.querySelector('#ops-log-source');
expect(sourceSelect).toBeTruthy();
const sourceOptions = Array.from(sourceSelect.options ?? []) as Array<{ value: string; selected: boolean }>;
sourceOptions.forEach((option) => {
option.selected = option.value === 'docker:whisper';
});
sourceSelect.dispatchEvent(new windowObj.Event('change', { bubbles: true }));
await flush();
expect(String(logsPanel.textContent ?? '')).toContain('model load failed');
const refreshButton = container.querySelector('#ops-log-refresh');
expect(refreshButton).toBeTruthy();
refreshButton.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush();
const logCalls = state.calls.filter((entry) => entry.method === 'system.serviceLogs');
expect(logCalls.length).toBeGreaterThanOrEqual(2);
expect(logCalls.some((entry) => entry.params?.sourceId === 'docker:whisper')).toBe(true);
});
});