Files
peardata/ui/logs.js
T
Raven Scott 1458915d78
CI / test (push) Successful in 56s
Release rolling / release (push) Successful in 7m4s
Docker work
2026-07-19 13:13:45 -04:00

512 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<object>,
* 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<typeof setInterval>|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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 += `<mark>${escapeHtml(text.slice(at, at + needle.length))}</mark>`
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(`
<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')
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="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 = `
<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()
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<typeof setTimeout>|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 }
}