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 }
}
+294 -38
View File
@@ -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;
}
}