Updates
This commit is contained in:
+344
-46
@@ -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">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')
|
||||
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 }
|
||||
}
|
||||
|
||||
+294
-38
@@ -2418,19 +2418,69 @@ code {
|
||||
|
||||
/* ─── Logs tab ─────────────────────────────────────────── */
|
||||
|
||||
#logs-view:not(.hidden) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logs-header .page-subtitle {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.logs-header-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#logs-follow.active {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-color));
|
||||
}
|
||||
|
||||
#logs-view.logs-following #logs-follow {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.logs-toolbar-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logs-sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.logs-sources .metrics-tf:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.logs-search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2439,19 +2489,25 @@ code {
|
||||
}
|
||||
|
||||
.logs-search-row #logs-q {
|
||||
flex: 1 1 180px;
|
||||
min-width: 140px;
|
||||
flex: 1 1 220px;
|
||||
min-width: 160px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logs-search-row #logs-q:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-color));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.logs-search-row #logs-unit {
|
||||
width: 120px;
|
||||
width: 140px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
@@ -2459,6 +2515,7 @@ code {
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.logs-journal-only {
|
||||
@@ -2467,56 +2524,255 @@ code {
|
||||
|
||||
#logs-view.logs-source-journal .logs-journal-only {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.logs-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 1.3em;
|
||||
}
|
||||
|
||||
.logs-status {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.logs-card {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#logs-view {
|
||||
.logs-status-error {
|
||||
color: var(--accent-danger, #e85d5d);
|
||||
}
|
||||
|
||||
.logs-copy-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
|
||||
.logs-stream {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#logs-view.hidden {
|
||||
.logs-stream.is-loading {
|
||||
opacity: 0.72;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.logs-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 72px minmax(90px, 140px) minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.logs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.logs-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.logs-list::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 72px minmax(90px, 140px) minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.log-row.log-tone-bad {
|
||||
border-left-color: var(--accent-danger, #e85d5d);
|
||||
}
|
||||
|
||||
.log-row.log-tone-warn {
|
||||
border-left-color: var(--accent-warning, #e0a24a);
|
||||
}
|
||||
|
||||
.log-row.log-tone-ok {
|
||||
border-left-color: var(--accent-success, #3cb371);
|
||||
}
|
||||
|
||||
.log-row.log-tone-info {
|
||||
border-left-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.log-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.log-rel {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-sev {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.log-sev-bad {
|
||||
color: var(--accent-danger, #e85d5d);
|
||||
background: color-mix(in srgb, var(--accent-danger, #e85d5d) 14%, transparent);
|
||||
}
|
||||
|
||||
.log-sev-warn {
|
||||
color: var(--accent-warning, #e0a24a);
|
||||
background: color-mix(in srgb, var(--accent-warning, #e0a24a) 14%, transparent);
|
||||
}
|
||||
|
||||
.log-sev-ok {
|
||||
color: var(--accent-success, #3cb371);
|
||||
background: color-mix(in srgb, var(--accent-success, #3cb371) 14%, transparent);
|
||||
}
|
||||
|
||||
.log-sev-info {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.log-unit {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-msg {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-msg mark {
|
||||
background: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.log-row:hover .log-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logs-empty.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logs-list .log-msg {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
.logs-empty-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logs-list .log-meta {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
.logs-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.logs-list li.log-sev-critical,
|
||||
.logs-list li.log-sev-err,
|
||||
.logs-list li.log-sev-emerg,
|
||||
.logs-list li.log-sev-alert {
|
||||
border-left-color: var(--danger, #e85d5d);
|
||||
.logs-footer:has(#logs-more:not(.hidden)) {
|
||||
border-top-color: var(--border-color);
|
||||
}
|
||||
|
||||
.logs-list li.log-sev-warning,
|
||||
.logs-list li.log-sev-warn {
|
||||
border-left-color: var(--warn, #e0a24a);
|
||||
}
|
||||
|
||||
.logs-sources .metrics-tf:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
@media (max-width: 900px) {
|
||||
.logs-cols {
|
||||
display: none;
|
||||
}
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.log-time {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.log-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user