Updates
CI / test (push) Successful in 1m3s
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-18 23:47:54 -04:00
parent e503e92dd4
commit 3fc0245b32
34 changed files with 834 additions and 197 deletions
+344 -46
View File
@@ -1,8 +1,15 @@
/**
* System Log tab — anomalies / audit / journal via queryLogs.
* 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: {
@@ -13,21 +20,36 @@ import { Roles, roleAllows } from '../shared/protocol.js'
* 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<object>,
* getRole: () => string,
* isConnected?: () => boolean,
* onShowChart?: (chartId: string, ts: number) => void,
* onCorrelate?: (ts: number) => void,
* }} opts
*/
export function createLogsView(opts) {
const state = {
/** Prefer host journal; falls back to anomaly when not admin */
source: 'journal',
range: '1h',
loading: false,
follow: false,
/** @type {ReturnType<typeof setInterval>|null} */
followTimer: null,
/** @type {object[]} */
entries: [],
nextCursor: /** @type {number|null} */ (null),
lastError: /** @type {string|null} */ (null),
gen: 0,
}
function escapeHtml(s) {
@@ -49,14 +71,67 @@ export function createLogsView(opts) {
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 += `<mark>${escapeHtml(text.slice(at, at + needle.length))}</mark>`
i = at + needle.length
}
return out
}
function syncSourceUi() {
const admin = isAdmin()
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
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')
@@ -78,42 +153,111 @@ export function createLogsView(opts) {
})
}
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 renderEntries(entries) {
function showEmpty(html) {
const empty = opts.els.empty
const list = opts.els.list
if (!list) return
list.innerHTML = ''
if (!entries.length) {
list.innerHTML = '<li class="muted">No matching log lines</li>'
if (list) list.innerHTML = ''
if (!empty) return
if (!html) {
empty.classList.add('hidden')
empty.innerHTML = ''
return
}
for (const row of entries) {
empty.classList.remove('hidden')
empty.innerHTML = html
}
function renderEmptyState() {
if (opts.isConnected && !opts.isConnected()) {
showEmpty(`
<p class="logs-empty-title">Agent offline</p>
<p class="muted">Connect an agent to query logs.</p>`)
return
}
if (state.lastError) {
showEmpty(`
<p class="logs-empty-title">Couldnt load logs</p>
<p class="muted">${escapeHtml(state.lastError)}</p>`)
return
}
const q = (opts.els.q?.value || '').trim()
showEmpty(`
<p class="logs-empty-title">No matching lines</p>
<p class="muted">${
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.`
}</p>`)
}
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')
li.className = `log-sev-${sev}`
const when = new Date(row.ts || Date.now()).toLocaleString()
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
? `<span class="fleet-actions anomaly-actions">
? `<span class="log-actions">
<button type="button" class="linkish" data-act="focus">Show</button>
<button type="button" class="linkish" data-act="correlate">Correlate</button>
</span>`
: ''
: `<span class="log-actions">
<button type="button" class="linkish" data-act="copy-line" title="Copy line">Copy</button>
</span>`
li.innerHTML = `
<strong>${escapeHtml(sev)}</strong>
<span class="log-msg">${escapeHtml(row.message || '')}</span>
<span class="muted log-meta">
<span>${escapeHtml(when)}</span>
${row.unit ? `<span>${escapeHtml(row.unit)}</span>` : ''}
<span>${escapeHtml(row.source || '')}</span>
</span>
<time class="log-time" datetime="${new Date(ts).toISOString()}" title="${escapeHtml(abs)}">
<span class="log-clock">${escapeHtml(formatClock(ts))}</span>
<span class="log-rel muted">${escapeHtml(formatRel(ts))}</span>
</time>
<span class="log-sev log-sev-${tone}" title="${escapeHtml(sev)}">${escapeHtml(sev)}</span>
<span class="log-unit" title="${escapeHtml(unit)}">${escapeHtml(unit)}</span>
<span class="log-msg">${highlight(row.message || '', q)}</span>
${actions}`
if (chart) {
li.querySelector('[data-act="focus"]')?.addEventListener('click', (e) => {
e.stopPropagation()
@@ -124,51 +268,185 @@ export function createLogsView(opts) {
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)
}
}
async function search() {
if (state.loading) return
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
setStatus('Searching')
opts.els.stream?.classList.add('is-loading')
opts.els.root?.setAttribute('aria-busy', 'true')
if (!silent && !append) setStatus('Searching…')
try {
const args = {
source: state.source,
q: opts.els.q?.value || '',
since: rangeToSince(),
until: Date.now(),
limit: 200,
}
if (state.source === 'journal') {
args.priority = opts.els.priority?.value || ''
args.unit = opts.els.unit?.value || ''
}
const args = buildArgs(append && state.nextCursor != null ? { cursor: state.nextCursor } : {})
const res = await opts.queryLogs(args)
if (gen !== state.gen) return
if (!res?.ok) {
setStatus(res?.hint || res?.error || 'Query failed', true)
renderEntries([])
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 : []
const n = res.stats?.returned ?? entries.length
setStatus(`${n} line${n === 1 ? '' : 's'} · ${state.source}`)
renderEntries(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) {
setStatus(err?.message || 'Query failed', true)
renderEntries([])
if (gen !== state.gen) return
state.lastError = err?.message || 'Query failed'
if (!append) {
state.entries = []
renderEntries()
}
setStatus(state.lastError, true)
} finally {
state.loading = false
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
state.source = btn.getAttribute('data-log-source') || 'anomaly'
state.nextCursor = null
syncSourceUi()
search().catch(() => {})
})
@@ -181,21 +459,36 @@ export function createLogsView(opts) {
})
})
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(() => {})
}
})
// Live filter for anomaly/audit: debounce input
opts.els.unit?.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
search().catch(() => {})
}
})
let t = /** @type {ReturnType<typeof setTimeout>|null} */ (null)
opts.els.q?.addEventListener('input', () => {
if (state.source === 'journal') return
if (t) clearTimeout(t)
t = setTimeout(() => search().catch(() => {}), 280)
// 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() {
@@ -203,7 +496,12 @@ export function createLogsView(opts) {
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, syncSourceUi }
return { search, enter, leave, syncSourceUi, setFollow }
}