512 lines
16 KiB
JavaScript
512 lines
16 KiB
JavaScript
/**
|
||
* 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, '&')
|
||
.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 += `<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">Couldn’t 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 }
|
||
}
|