/** * System Log tab — journal / anomalies / audit via queryLogs. */ import { Roles, roleAllows } from '../shared/protocol.js' const FOLLOW_MS = 5000 const SOURCE_LABEL = { journal: 'Journal', anomaly: 'Anomalies', audit: 'Audit', } /** * @param {{ * els: { * root: HTMLElement|null, * sources: HTMLElement|null, * q: HTMLInputElement|null, * presets: HTMLElement|null, * priority: HTMLSelectElement|null, * unit: HTMLInputElement|null, * searchBtn: HTMLElement|null, * clearBtn?: HTMLElement|null, * status: HTMLElement|null, * list: HTMLElement|null, * empty?: HTMLElement|null, * moreBtn?: HTMLElement|null, * followBtn?: HTMLElement|null, * refreshBtn?: HTMLElement|null, * copyBtn?: HTMLElement|null, * stream?: HTMLElement|null, * }, * queryLogs: (args: object) => Promise, * getRole: () => string, * isConnected?: () => boolean, * onShowChart?: (chartId: string, ts: number) => void, * onCorrelate?: (ts: number) => void, * }} opts */ export function createLogsView(opts) { const state = { /** Active source shown in the UI (may temporarily fall back when role is insufficient). */ source: 'journal', /** User/default preference — Journal is the Logs tab default. */ preferredSource: 'journal', range: '1h', loading: false, follow: false, /** @type {ReturnType|null} */ followTimer: null, /** @type {object[]} */ entries: [], nextCursor: /** @type {number|null} */ (null), lastError: /** @type {string|null} */ (null), gen: 0, } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function isAdmin() { return roleAllows(opts.getRole?.() || Roles.viewer, Roles.admin) } function rangeToSince() { const now = Date.now() const map = { '15m': 15, '1h': 60, '6h': 360, '24h': 1440 } const mins = map[state.range] || 60 return now - mins * 60_000 } function formatRel(ts) { const d = Date.now() - ts if (!Number.isFinite(d) || d < 0) return 'now' if (d < 60_000) return `${Math.max(1, Math.round(d / 1000))}s` if (d < 3_600_000) return `${Math.round(d / 60_000)}m` if (d < 86_400_000) return `${Math.round(d / 3_600_000)}h` return `${Math.round(d / 86_400_000)}d` } function formatClock(ts) { try { return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) } catch { return '—' } } function sevClass(sev) { const s = String(sev || 'info').toLowerCase() if (['emerg', 'alert', 'crit', 'critical', 'err', 'error'].includes(s)) return 'bad' if (['warning', 'warn'].includes(s)) return 'warn' if (['ok', 'notice', 'cleared'].includes(s)) return 'ok' return 'info' } function highlight(msg, q) { const text = String(msg || '') const needle = String(q || '').trim() if (!needle) return escapeHtml(text) const lower = text.toLowerCase() const n = needle.toLowerCase() let out = '' let i = 0 while (i < text.length) { const at = lower.indexOf(n, i) if (at < 0) { out += escapeHtml(text.slice(i)) break } out += escapeHtml(text.slice(i, at)) out += `${escapeHtml(text.slice(at, at + needle.length))}` i = at + needle.length } return out } function resolveSource() { const preferred = state.preferredSource || 'journal' const needsAdmin = preferred === 'audit' || preferred === 'journal' if (needsAdmin && !isAdmin()) return 'anomaly' return preferred } function syncSourceUi() { const admin = isAdmin() state.source = resolveSource() opts.els.root?.classList.toggle('logs-source-journal', state.source === 'journal') opts.els.root?.classList.toggle('logs-following', state.follow) opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => { const src = btn.getAttribute('data-log-source') || '' const needsAdmin = src === 'audit' || src === 'journal' btn.disabled = needsAdmin && !admin btn.title = needsAdmin && !admin ? 'Admin role required' : btn.dataset.titleDefault || btn.title const active = src === state.source btn.classList.toggle('active', active) btn.setAttribute('aria-selected', active ? 'true' : 'false') }) } function syncRangeUi() { opts.els.presets?.querySelectorAll('[data-log-range]').forEach((btn) => { btn.classList.toggle('active', btn.getAttribute('data-log-range') === state.range) }) } function syncFollowUi() { const btn = opts.els.followBtn if (!btn) return btn.classList.toggle('active', state.follow) btn.setAttribute('aria-pressed', state.follow ? 'true' : 'false') btn.textContent = state.follow ? 'Following' : 'Follow' opts.els.root?.classList.toggle('logs-following', state.follow) } function setStatus(text, isError = false) { if (!opts.els.status) return opts.els.status.textContent = text || '' opts.els.status.classList.toggle('logs-status-error', Boolean(isError)) } function showEmpty(html) { const empty = opts.els.empty const list = opts.els.list if (list) list.innerHTML = '' if (!empty) return if (!html) { empty.classList.add('hidden') empty.innerHTML = '' return } empty.classList.remove('hidden') empty.innerHTML = html } function renderEmptyState() { if (opts.isConnected && !opts.isConnected()) { showEmpty(`

Agent offline

Connect an agent to query logs.

`) return } if (state.lastError) { showEmpty(`

Couldn’t load logs

${escapeHtml(state.lastError)}

`) return } const q = (opts.els.q?.value || '').trim() showEmpty(`

No matching lines

${ q ? `Nothing matched “${escapeHtml(q)}” in ${escapeHtml(SOURCE_LABEL[state.source] || state.source)}.` : `No ${escapeHtml(SOURCE_LABEL[state.source] || state.source)} lines in this window.` }

`) } function renderEntries(append = false) { const list = opts.els.list if (!list) return const q = opts.els.q?.value || '' if (!append) list.innerHTML = '' if (!state.entries.length) { if (opts.els.copyBtn) opts.els.copyBtn.hidden = true if (opts.els.moreBtn) opts.els.moreBtn.classList.add('hidden') renderEmptyState() return } showEmpty('') if (opts.els.copyBtn) opts.els.copyBtn.hidden = false if (opts.els.moreBtn) { opts.els.moreBtn.classList.toggle('hidden', state.nextCursor == null) } const start = append ? list.children.length : 0 const slice = append ? state.entries.slice(start) : state.entries for (const row of slice) { const li = document.createElement('li') const sev = String(row.severity || 'info') const tone = sevClass(sev) li.className = `log-row log-tone-${tone}` li.dataset.id = String(row.id || '') const ts = Number(row.ts) || Date.now() const chart = row.fields?.chart const unit = row.unit || '—' const abs = new Date(ts).toLocaleString() const actions = row.source === 'anomaly' && chart ? ` ` : ` ` li.innerHTML = ` ${escapeHtml(sev)} ${escapeHtml(unit)} ${highlight(row.message || '', q)} ${actions}` if (chart) { li.querySelector('[data-act="focus"]')?.addEventListener('click', (e) => { e.stopPropagation() opts.onShowChart?.(chart, row.ts) }) li.querySelector('[data-act="correlate"]')?.addEventListener('click', (e) => { e.stopPropagation() opts.onCorrelate?.(row.ts) }) } li.querySelector('[data-act="copy-line"]')?.addEventListener('click', async (e) => { e.stopPropagation() const line = `${abs}\t${sev}\t${unit}\t${row.message || ''}` try { await navigator.clipboard.writeText(line) setStatus('Copied line') } catch { setStatus('Copy failed', true) } }) list.appendChild(li) } } function buildArgs(extra = {}) { const args = { source: state.source, q: opts.els.q?.value || '', since: rangeToSince(), until: Date.now(), limit: 200, ...extra, } if (state.source === 'journal') { args.priority = opts.els.priority?.value || '' args.unit = opts.els.unit?.value || '' } return args } /** * @param {{ append?: boolean, silent?: boolean }} [optsIn] */ async function search(optsIn = {}) { const append = Boolean(optsIn.append) const silent = Boolean(optsIn.silent) if (state.loading && !silent) return syncSourceUi() if (opts.isConnected && !opts.isConnected()) { state.entries = [] state.nextCursor = null state.lastError = null renderEntries() setStatus('Offline — connect an agent', true) return } const gen = ++state.gen state.loading = true opts.els.stream?.classList.add('is-loading') opts.els.root?.setAttribute('aria-busy', 'true') if (!silent && !append) setStatus('Searching…') try { const args = buildArgs(append && state.nextCursor != null ? { cursor: state.nextCursor } : {}) const res = await opts.queryLogs(args) if (gen !== state.gen) return if (!res?.ok) { state.entries = append ? state.entries : [] state.nextCursor = null state.lastError = res?.hint || res?.error || 'Query failed' if (!append) renderEntries() else renderEmptyState() setStatus(state.lastError, true) return } const entries = Array.isArray(res.entries) ? res.entries : [] state.lastError = null state.nextCursor = res.nextCursor ?? null if (append) state.entries = state.entries.concat(entries) else state.entries = entries const n = state.entries.length const label = SOURCE_LABEL[state.source] || state.source const followBit = state.follow ? ' · following' : '' setStatus(`${n} line${n === 1 ? '' : 's'} · ${label}${followBit}`) renderEntries(append) } catch (err) { if (gen !== state.gen) return state.lastError = err?.message || 'Query failed' if (!append) { state.entries = [] renderEntries() } setStatus(state.lastError, true) } finally { if (gen === state.gen) { state.loading = false opts.els.stream?.classList.remove('is-loading') opts.els.root?.removeAttribute('aria-busy') } } } function setFollow(on) { state.follow = Boolean(on) if (state.followTimer) { clearInterval(state.followTimer) state.followTimer = null } if (state.follow) { state.followTimer = setInterval(() => { if (opts.els.root?.classList.contains('hidden')) return search({ silent: true }).catch(() => {}) }, FOLLOW_MS) } syncFollowUi() } function clearFilters() { if (opts.els.q) opts.els.q.value = '' if (opts.els.unit) opts.els.unit.value = '' if (opts.els.priority) opts.els.priority.value = '' search().catch(() => {}) } async function copyVisible() { if (!state.entries.length) return const text = state.entries .map((row) => { const abs = new Date(row.ts || Date.now()).toISOString() return `${abs}\t${row.severity || ''}\t${row.unit || ''}\t${row.message || ''}` }) .join('\n') try { await navigator.clipboard.writeText(text) setStatus(`Copied ${state.entries.length} lines`) } catch { setStatus('Copy failed', true) } } function bindKeyboard() { window.addEventListener('keydown', (ev) => { const root = opts.els.root if (!root || root.classList.contains('hidden')) return const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || '' const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' if (ev.key === '/' && !typing) { ev.preventDefault() opts.els.q?.focus() opts.els.q?.select() return } if (ev.key === 'Escape') { if (typing && opts.els.q && document.activeElement === opts.els.q) { if (opts.els.q.value) { opts.els.q.value = '' search().catch(() => {}) } else { opts.els.q.blur() } ev.preventDefault() } return } if (typing) return if (ev.key === 'r' || ev.key === 'R') { ev.preventDefault() search().catch(() => {}) } else if (ev.key === 'f' || ev.key === 'F') { ev.preventDefault() setFollow(!state.follow) } }) } function bind() { opts.els.sources?.querySelectorAll('[data-log-source]').forEach((btn) => { if (!btn.dataset.titleDefault) btn.dataset.titleDefault = btn.title || '' btn.addEventListener('click', () => { if (btn.disabled) return const src = btn.getAttribute('data-log-source') || 'journal' state.preferredSource = src state.source = src state.nextCursor = null syncSourceUi() search().catch(() => {}) }) }) opts.els.presets?.querySelectorAll('[data-log-range]').forEach((btn) => { btn.addEventListener('click', () => { state.range = btn.getAttribute('data-log-range') || '1h' syncRangeUi() search().catch(() => {}) }) }) opts.els.searchBtn?.addEventListener('click', () => search().catch(() => {})) opts.els.clearBtn?.addEventListener('click', () => clearFilters()) opts.els.refreshBtn?.addEventListener('click', () => search().catch(() => {})) opts.els.followBtn?.addEventListener('click', () => setFollow(!state.follow)) opts.els.copyBtn?.addEventListener('click', () => copyVisible().catch(() => {})) opts.els.moreBtn?.addEventListener('click', () => search({ append: true }).catch(() => {})) opts.els.priority?.addEventListener('change', () => search().catch(() => {})) opts.els.q?.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault() search().catch(() => {}) } }) opts.els.unit?.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault() search().catch(() => {}) } }) let t = /** @type {ReturnType|null} */ (null) opts.els.q?.addEventListener('input', () => { if (t) clearTimeout(t) // Live for all sources; journal also benefits from debounce + Search t = setTimeout(() => search().catch(() => {}), state.source === 'journal' ? 450 : 280) }) bindKeyboard() syncSourceUi() syncRangeUi() syncFollowUi() } function enter() { syncSourceUi() search().catch(() => {}) } function leave() { // keep follow timer but search() no-ops while hidden; stop to save RPC if (state.follow) setFollow(false) } bind() return { search, enter, leave, syncSourceUi, setFollow } }